Technical Articles
Getting Started with Kapsel – Part 10 — Offline OData(SP13+)
The Kapsel Offline OData plugin enables an OData version 2.0 based application that has its data proxied through an SMP or SAP Cloud Platform Mobile Services server to be used when a device or emulator is offline by creating a store on the device using the code below.
var properties = {
"name": "ProductsOfflineStore",
"host": applicationContext.registrationContext.serverHost,
"port": getServerPort(),
"https": applicationContext.registrationContext.https,
"serviceRoot": appId,
"definingRequests": {
"ProductsDR": "/Products",
"SuppliersDR": "/Suppliers"
}
};
store = sap.OData.createOfflineStore(properties);
store.open(openStoreSuccessCallback, errorCallback);
A set of defining requests specify the data that will populate the offline store.
Once the store is open and the custom OData client is applied with the following call, all calls made using the datajs library that match the service root (connection name for the endpoint such as com.kapsel.gs) are routed to the offline store.
sap.OData.applyHttpClient();
//sap.OData.removeHttpClient();
The data that will be updated when a device is offline should ideally be data that multiple offline users are not simultaneously updating. If possible, partition the data to the subset required by each user. Adding OData filters to the defining requests to limit the data on the device to the subset of data required is one technique to do this.
The offline store has a 16 GB limit. The initial download time, database size and refresh times can be affected by the amount of data being stored on the device.
An offline store can only be used with one OData service. For example, one offline store could be associated with one service such as
http://services.odata.org/V2/OData/OData.svc/
or
http://services.odata.org/V2/Northwind/Northwind.svc/
but not both.
Multiple stores can however exist on a device.
Once the offline store has been created, local changes made to it can be sent out via a call to store.flush().
The offline store can update its copy of the data by calling store.refresh().
OData function imports are not supported as the service metadata document does not describe what changes occur as a result of calling them. See also Offline OData Version Support for further details on supported features.
For additional details on the Offline OData plugin see Week 5 Unit 1 of Developing Mobile Apps with SAP HANA Cloud Platform, Offline Enablement for SAP Fiori, C:\SAP\MobileSDK3\KapselSDK\docs\api\sap.OData.html or Using the Offline OData Plugin.
The following samples will help demonstrate the Kapsel Offline OData plugin.
Accessing Data When Offline
The Offline OData plugin is added to the project to enable the accessing of OData when device is offline.
Using ILOData to Query an Offline Database
The offline database is copied from a device and then OData queries can be made directly against the database. This can be useful when troubleshooting or for technical support.
Encrypting the Offline Database
A store option named storeEncryptionKey can be provided when creating the offline store to encrypt the data within it.
Offline enabled App with Create, Update, Delete, Access to the Error Archive
Adds CUD operations (create, update, delete) and how to view failed updates in the ErrorArchive.
Error Conditions
Explores a few of the common error conditions that can occur.
Auto Increment Primary Keys
Mentions where to get the temporary primary key value from.
Offline and Online in One App
Demonstrates an app where a portion of the data is made available when offline.
Accessing Data When Offline
The following steps will enable the app to be used when the device or simulator is online or offline.
Note, the techniques used in this sample are for learning purposes only. In a non-demo app, it is recommended that the offline store be used all the time to maintain consistency of the data.
- Add the Cordova network information plugin, and the Kapsel Logger and Offline OData plugins.
cordova plugin add cordova-plugin-network-information cordova plugin add kapsel-plugin-logger --searchpath %KAPSEL_HOME%/plugins cordova plugin add kapsel-plugin-odata --searchpath %KAPSEL_HOME%/plugins or cordova plugin add kapsel-plugin-logger --searchpath $KAPSEL_HOME/plugins cordova plugin add kapsel-plugin-odata --searchpath $KAPSEL_HOME/plugins
- The Offline OData plugin requires that the OData source is proxied through a SMP or SAP Cloud Platform Mobile Services server.
Ensure the rewrite mode is Rewrite URL as shown below. For offline apps it is important that the URI’s are routed through an SMP or SAP Cloud Platform Mobile Services server.
- Create a file named offline.ini.
Copy the following text into the file.[endpoint] name=com.kapsel.gs allow_omitting_max_length_facet=Y
Import this file into the management cockpit under the tab OFFLINE CONFIGURATION.
This setting provides a default value for Edm.String and Edm.Binary key values as this OData service did not provide these values.
For additional details, see Application Configuration File. - Replace www/index.html with the following contents.
<!DOCTYPE html> <html> <head> <meta name="viewport" content="user-scalable=no, initial-scale=1, maximum-scale=1, minimum-scale=1, width=device-width"> <script type="text/javascript" charset="utf-8" src="datajs-1.1.2.min.js"></script> <script type="text/javascript" charset="utf-8" src="serverContext.js"></script> <script type="text/javascript" charset="utf-8" src="cordova.js"></script> <script> var applicationContext = null; var online = false; var store = null; //Offline OData store var startTime = new Date(); var initTime = null; var unlockTime = null; var resumeTime = null; window.onerror = onError; function onError(msg, url, line) { var idx = url.lastIndexOf("/"); var file = "unknown"; if (idx > -1) { file = url.substring(idx + 1); } alert("An error occurred in " + file + " (at line # " + line + "): " + msg); return false; //suppressErrorAlert; } function init() { updateStatus2("Calling Logon.init"); initTime = new Date(); var endTime = new Date(); var duration = (endTime - startTime)/1000; console.log("EventLogging: Time from onload to deviceready " + duration + " seconds"); if (sap.Logger) { sap.Logger.setLogLevel(sap.Logger.DEBUG); //enables the display of debug log messages from the Kapsel plugins. sap.Logger.debug("EventLogging: Log level set to DEBUG"); } if (navigator.notification) { // Override default HTML alert with native dialog. alert is not supported on Windows window.alert = navigator.notification.alert; } register(); console.log("EventLogging: init completed"); } function logonSuccessCallback(result) { updateStatus2("logonSuccessCallback called"); var endTime = new Date(); if (unlockTime) { var duration = (endTime - unlockTime)/1000; console.log("EventLogging: Unlock Time " + duration + " seconds"); unlockTime = null; } console.log("EventLogging: logonSuccessCallback " + JSON.stringify(result)); applicationContext = result; showScreen("MainDiv"); } function logonErrorCallback(error) { //this method is called if the user cancels the registration. alert("An error occurred: " + JSON.stringify(error)); if (device.platform == "Android") { //Not supported on iOS navigator.app.exitApp(); } } function read() { updateStatus2("Read request started"); startTime = new Date(); if (!applicationContext) { alert("Register or unlock before proceeding"); return; } clearTable(); sUrl = applicationContext.applicationEndpointURL + "/CarrierCollection?$format=json&$orderby=carrid"; //JSON format is less verbose than atom/xml var oHeaders = {}; //oHeaders['X-SMP-APPCID'] = applicationContext.applicationConnectionId; //not needed as this will be sent by the logon plugin var request = { headers : oHeaders, requestUri : sUrl, method : "GET" }; if (device.platform == "windows") { //provided by the authproxy and logon plugins on Android and iOS but not on Windows https://support.wdf.sap.corp/sap/support/message/1680272744 request.user = applicationContext.registrationContext.user; request.password = applicationContext.registrationContext.password; } OData.read(request, readSuccessCallback, errorCallback); } function readSuccessCallback(data, response) { var endTime = new Date(); var duration = (endTime - startTime)/1000; updateStatus2("Read " + data.results.length + " records in " + duration + " seconds"); var carrierTable = document.getElementById("carrierTable"); for (var i = data.results.length -1; i >= 0; i--) { var row = carrierTable.insertRow(1); var cell1 = row.insertCell(0); var cell2 = row.insertCell(1); cell1.innerHTML = data.results[i].carrid; cell2.innerHTML = data.results[i].CARRNAME; } } function clearTable() { var carrierTable = document.getElementById("carrierTable"); while(carrierTable.rows.length > 1) { carrierTable.deleteRow(1); } } function errorCallback(e) { alert("An error occurred: " + JSON.stringify(e)); showScreen("MainDiv"); } function register() { updateStatus2("Calling Logon.init"); sap.Logon.init(logonSuccessCallback, logonErrorCallback, appId, context); } function unRegister() { showScreen("LoadingDiv"); updateStatus2("Calling deleteRegistration"); sap.Logon.core.deleteRegistration(logonUnregisterSuccessCallback, errorCallback); clearTable(); } function logonUnregisterSuccessCallback(result) { updateStatus2("Successfully Unregistered"); console.log("EventLogging: logonUnregisterSuccessCallback " + JSON.stringify(result)); applicationContext = null; register(); } function lock() { sap.Logon.lock(logonLockSuccessCallback, errorCallback); clearTable(); } function logonLockSuccessCallback(result) { console.log("EventLogging: logonLockSuccessCallback " + JSON.stringify(result)); applicationContext = null; showScreen("LockedDiv"); //sap.Logon.unlock(function () {},function (error) {}); //alternatively show the unlock screen } function unlock() { unlockTime = new Date(); sap.Logon.unlock(logonSuccessCallback, errorCallback); } function managePasscode() { sap.Logon.managePasscode(managePasscodeSuccessCallback, errorCallback); } function managePasscodeSuccessCallback() { console.log("EventLogging: managePasscodeSuccess"); } function showScreen(screenIDToShow) { var screenToShow = document.getElementById(screenIDToShow); screenToShow.style.display = "block"; var screens = document.getElementsByClassName('screenDiv'); for (var i = 0; i < screens.length; i++) { if (screens[i].id != screenToShow.id) { screens[i].style.display = "none"; } } } function openStore() { console.log("EventLogging: openStore"); startTime = new Date(); updateStatus2("store.open called"); var properties = { "name": "CarrierOfflineStore", "host": applicationContext.registrationContext.serverHost, "port": applicationContext.registrationContext.serverPort, "https": applicationContext.registrationContext.https, "serviceRoot" : appId, //"storePath" : cordova.file.externalRootDirectory, "definingRequests" : { "CarriersDR" : "/CarrierCollection" } }; if (device.platform == "windows") { var authStr = "Basic " + btoa(applicationContext.registrationContext.user + ":" + applicationContext.registrationContext.password); properties.streamParams = "custom_header=Authorization:" + authStr; } store = sap.OData.createOfflineStore(properties); var options = {}; store.open(openStoreSuccessCallback, errorCallback, options, progressCallback); } function openStoreSuccessCallback() { var endTime = new Date(); var duration = (endTime - startTime)/1000; updateStatus2("Store opened in " + duration + " seconds"); updateStatus1("Store is OPEN."); console.log("EventLogging: openStoreSuccessCallback. Stored opened in " + duration); sap.OData.applyHttpClient(); //Offline OData calls can now be made against datajs. } function closeStore() { if (!store) { updateStatus2("The store must be opened before it can be closed"); return; } console.log("EventLogging: closeStore"); updateStatus2("store.close called"); store.close(closeStoreSuccessCallback, errorCallback); } function closeStoreSuccessCallback() { console.log("EventLogging: closeStoreSuccessCallback"); sap.OData.removeHttpClient(); updateStatus1("Store is CLOSED."); updateStatus2("Store closed"); } //Removes the physical store from the filesystem function clearStore() { console.log("EventLogging: clearStore"); if (!store) { updateStatus2("The store must be closed before it can be cleared"); return; } store.clear(clearStoreSuccessCallback, errorCallback); } function clearStoreSuccessCallback() { console.log("EventLogging: clearStoreSuccessCallback"); updateStatus1(""); updateStatus2("Store is CLEARED"); store = null; } function refreshStore() { console.log("EventLogging: refreshStore"); if (!store) { updateStatus2("The store must be open before it can be refreshed"); return; } startTime = new Date(); updateStatus2("Store refresh called"); store.refresh(refreshStoreCallback, errorCallback, null, progressCallback); } function refreshStoreCallback() { console.log("EventLogging: refreshStoreCallback"); var endTime = new Date(); var duration = (endTime - startTime)/1000; updateStatus2("Store refreshed in " + duration + " seconds"); } function flushStore() { console.log("EventLogging: flushStore"); if (!store) { updateStatus2("The store must be open before it can be flushed"); return; } startTime = new Date(); updateStatus2("Store flush called"); store.flush(flushStoreSuccessCallback, errorCallback, null, progressCallback); } function flushStoreSuccessCallback() { console.log("EventLogging: flushStoreSuccessCallback"); var endTime = new Date(); var duration = (endTime - startTime)/1000; updateStatus2("Store flushed in " + duration + " seconds"); refreshStore(); } function viewLog() { if (sap.Logger) { sap.Logger.getLogEntries(getLogEntriesSuccess, errorCallback) } else { alert("ensure the kapsel-logger plugin has been added to the project"); } } function getLogEntriesSuccess(logEntries) { var stringToShow = ""; var logArray; if (device.platform == "windows") { logArray = logEntries.split("\n"); if (logArray.length > 0) { for (var i = 0; i < logArray.length; i++) { stringToShow += logArray[i] + "\n"; } } } else if (device.platform == "iOS") { logArray = logEntries.split("\n"); if (logArray.length > 0) { for (var i = 0; i < logArray.length; i++) { logLineEntries = logArray[i].split(" "); for (var j = 7; j < logLineEntries.length; j++) { stringToShow += logLineEntries[j] + " "; } stringToShow = stringToShow + "\n"; } } } else { //Android logArray = logEntries.split('#'); if (logArray.length > 0) { var numOfMessages = parseInt(logArray.length / 15); for (var i = 0; i < numOfMessages; i++) { stringToShow += logArray[i * 15 + 1] + ": " + logArray[i * 15 + 3] + ": " + logArray[i * 15 + 14] + "\n"; } } } alert(stringToShow); console.log("EventLogging: Device Log follows " + stringToShow); } function updateStatus1(msg) { document.getElementById('statusID').innerHTML = msg + " " + getDeviceStatusString(); console.log("EventLogging: " + msg + " " + getDeviceStatusString()); } function updateStatus2(msg) { var d = new Date(); document.getElementById('statusID2').innerHTML = msg + " at " + addZero(d.getHours()) + ":" + addZero(d.getMinutes()) + "." + addZero(d.getSeconds()); console.log("EventLogging: " + msg + " at " + addZero(d.getHours()) + ":" + addZero(d.getMinutes()) + "." + addZero(d.getSeconds())); } function addZero(input) { if (input < 10) { return "0" + input; } return input; } function getDeviceStatusString() { if (online) { return "Device is ONLINE"; } else { return "Device is OFFLINE"; } } function progressCallback(progressStatus) { var status = progressStatus.progressState; var lead = "unknown"; if (status === sap.OfflineStore.ProgressState.STORE_DOWNLOADING) { lead = "Downloading "; } else if (status === sap.OfflineStore.ProgressState.REFRESH) { lead = "Refreshing "; } else if (status === sap.OfflineStore.ProgressState.FLUSH_REQUEST_QUEUE) { lead = "Flushing "; } else if (status === sap.OfflineStore.ProgressState.DONE) { lead = "Complete "; } else { alert("Unknown status in progressCallback"); } updateStatus2(lead + "Sent: " + progressStatus.bytesSent + " Received: " + progressStatus.bytesRecv + " File Size: " + progressStatus.fileSize); } function deviceOnline() { online = true; updateStatus1(""); } function deviceOffline() { online = false; updateStatus1(""); } function onLoad() { console.log("EventLogging: onLoad"); } function onBeforeUnload() { console.log("EventLogging: onBeforeUnLoad"); } function onUnload() { console.log("EventLogging: onUnload"); } function onPause() { console.log("EventLogging: onPause"); } function onResume() { resumeTime = new Date(); console.log("EventLogging: onResume"); } function onSapResumeSuccess() { console.log("EventLogging: onSapResumeSuccess"); var endTime = new Date(); var duration = (endTime - resumeTime)/1000; console.log("EventLogging: Time from onresume to onSapResumeSuccess " + duration + " seconds"); } function onSapLogonSuccess() { console.log("EventLogging: onSapLogonSuccess"); } function onSapResumeError(error) { console.log("EventLogging: onSapResumeError " + JSON.stringify(error)); } document.addEventListener("deviceready", init, false); document.addEventListener("pause", onPause, false); document.addEventListener("resume", onResume, false); document.addEventListener("online", deviceOnline, false); document.addEventListener("offline", deviceOffline, false); document.addEventListener("onSapResumeSuccess", onSapResumeSuccess, false); document.addEventListener("onSapLogonSuccess", onSapLogonSuccess, false); document.addEventListener("onSapResumeError", onSapResumeError, false); </script> </head> <body onload="onLoad()" onunload="onUnload()" onbeforeunload="onBeforeUnload()"> <div class="screenDiv" id="LoadingDiv"> <h1>Loading ...</h1> </div> <div class="screenDiv" id="LockedDiv" style="display: none"> <h1>Locked</h1> <button id="unlock2" onclick="unlock()">Unlock</button> </div> <div class="screenDiv" id="MainDiv" style="display: none"> <h1>Offline Sample</h1> <button id="read" onclick="read()">Read</button> <button id="unregister" onclick="unRegister()">Unregister</button> <button id="openStore" onclick="setTimeout(openStore, 10);">Open Offline Store</button> <button id="closeStore" onclick="closeStore()">Close Offline Store</button> <button id="clearStore" onclick="clearStore()">Clear Offline Store</button> <button id="sync" onclick="flushStore()">Flush and Refresh</button> <button id="clearLog" onclick="sap.Logger.clearLog();updateStatus2('Cleared the Log')">Clear Log</button><br> <button id="viewLog" onclick="viewLog()">View Log</button><br> <span id="statusID"></span><br> <span id="statusID2"></span> <table id="carrierTable"><tr><th>Carrier ID</th><th>Carrier Name</th></tr></table> </div> </body> </html>
- Prepare, build and deploy the app with the following command.
cordova run android or cordova run windows --archs=x86 or cordova run ios
Click on Open Offline Store.
Place the device into airplane mode.
Airplane mode can be turned on in the following manner.
On Android choose Settings > More > Airplane mode
On an iOS device choose Settings > Airplane Mode.
On an iOS simulator, unplug the Ethernet cable or turn off the WI-FI for the Mac hosting the simulator.
Click on Read.
Notice that the app now returns the results using the data retrieved from the local store and does so much quicker since a network request is not needed to retrieve the results.
Using ILOData to Query an Offline Database
The steps below will demonstrate how to take an already created database from an application and query it with ILOData.
Note, it is possible to create an offline database directly with ILOData tool.
Browse to the <Install Media>\modules\OfflineODataTools folder. There is a zip file named OfflineODataTools.zip. Unzip the file to extract iLoData.exe.
Note there are two database files, one named .udb and one name .rq.udb.
udb stands for UltraLite database and rq stands for request queue.
iOS
In Xcode > Windows > Devices, select your connected device, select your deployed Kapsel app, and in the gear at the bottom choose Download Container.
Once downloaded, select it with finder and choose Show Package Contents. The offline database files are shown below in their default location if a path is not provided via the storepath option.
Android
To retrieve the database files from an Android device, specify the store path when creating the offline database. Modify the index.html file and in the openStore method, uncomment the following entry.
"storePath" : cordova.file.externalRootDirectory,
Add the file plugin.
cordova plugin add cordova-plugin-file
Deploy and run the app. Once the store has been recreated, the database files can be retrieved using following commands.
adb shell cd /sdcard ls CarrierOfflineStore.* exit
adb pull /sdcard/CarrierOfflineStore.udb adb pull /sdcard/CarrierOfflineStore.rq.udb
If you are using an Android emulator, you can execute the below commands directly without using the storePath option.
adb root adb shell cd /data/data/com.kapsel.gs/files ls -l exit adb pull /data/data/com.kapsel.gs/files/CarrierOfflineStore.rq.udb adb pull /data/data/com.kapsel.gs/files/CarrierOfflineStore.udb
Windows
For a Kapsel app deployed to a Windows laptop, the database files can be found under the following folder.
C:\Users\i8xxxx\AppData\Local\Packages\com.kapsel.gs_h35559jr9hy9m\LocalState
Using ILOData
C:\SAP\MobileSDK3\ilodata.exe store_name=./CarrierOfflineStore get /CarrierCollection?$top=1
Encrypting the Offline Database
To encrypt the data in the offline stores, specify an encryption key. Without this, it is possible on an Android or iOS simulator to access the UltraLite database files and view the data as shown below.
The following option can be provided when opening the store to encrypt the stores using AES-256.
var options = { "storeEncryptionKey" : "myVerySecretKey123!" //"storeEncryptionKey" : applicationContext.registrationContext.password //if using the Logon plugin, the user entered password for the registration can be used if it never changes }; store.open(openStoreSuccessCallback, errorCallback, options);
A better solution than hardcoding the password would be to generate a random password and then to store it in the datavault provided by the Logon plugin. Here is an example of how that might look.
var otp = null; //one time password. Must be set once the first time the app starts
function logonSuccessCallback() {
getOneTimePassword();
...
}
function getOneTimePassword() {
sap.Logon.get(getSuccess, errorCallback, "key");
}
function getSuccess(val) {
if (val == null) {
val = Math.random().toString(36).substring(16);
sap.Logon.set(val);
return;
}
otp = val;
}
function openStore() {
...
var options = {
"storeEncryptionKey" : otp
};
store.open(openStoreSuccessCallback, errorCallback, options);
}
Offline Enabled App with Create, Update, Delete and Access to the Error Archive
The following steps will create an offline enabled app that can create, update and delete records and will also display the contents of the ErrorArchive which contains any error that occurred during a flush. A flush attempts to send locally made changes on the device to the OData producer.
This sample uses the SAP Cloud Platform Mobile Services server as it provides the sample OData service that will be used.
Note, the techniques used in this sample are for learning purposes only. In a non-demo app, it is recommended that the offline store be used all the time to maintain consistency of the data.
- In the management cockpit create a new application with an ID of
com.kapsel.gs2
Set the Type to be Hybrid.
Set the Security Configuration to be Basic.
On the Back End tab, set an endpoint with the following value.
https://hcpms-i82XXXXtrial.hanatrial.ondemand.com/SampleServices/ESPM.svc
Set the Proxy Type to be Internet.
Ensure the rewrite mode is Rewrite URL. For offline apps it is important that the URI’s are routed through the SMP or SAP Cloud Platform Mobile Services server.
Add a Basic Authentication or Application to Application SSO mechanism.
- Create the project.
cordova create C:\Kapsel_Projects\KapselGSDemo2 com.kapsel.gs2 KapselGSDemo2 cd C:\Kapsel_Projects\KapselGSDemo2 cordova platform add android cordova platform add windows or on iOS cordova create ~/Documents/Kapsel_Projects/KapselGSDemo2 com.kapsel.gs2 KapselGSDemo2 cd ~/Documents/Kapsel_Projects/KapselGSDemo2 cordova platform add ios
- Add the Cordova network information plugin and the Kapsel Offline OData plugin.
cordova plugin add cordova-plugin-network-information cordova plugin add cordova-plugin-dialogs cordova plugin add kapsel-plugin-odata --searchpath %KAPSEL_HOME%/plugins cordova plugin add kapsel-plugin-logon --searchpath %KAPSEL_HOME%/plugins cordova plugin add kapsel-plugin-logger --searchpath %KAPSEL_HOME%/plugins or cordova plugin add kapsel-plugin-odata --searchpath $KAPSEL_HOME/plugins cordova plugin add kapsel-plugin-logon --searchpath $KAPSEL_HOME/plugins cordova plugin add kapsel-plugin-logger --searchpath $KAPSEL_HOME/plugins
- Replace C:\Kapsel_Projects\KapselGSDemo2\www\index.html with the contents below.
<!DOCTYPE html> <html> <head> <meta name="viewport" content="user-scalable=no, initial-scale=1, maximum-scale=1, minimum-scale=1, width=device-width"> <!--<meta http-equiv="Content-Security-Policy" content="default-src http://10.7.171.234:8080 'self' 'unsafe-inline' data: gap: https://ssl.gstatic.com 'unsafe-eval'; style-src 'self' 'unsafe-inline'; media-src *">--> <script type="text/javascript" charset="utf-8" src="datajs-1.1.2.min.js"></script> <script type="text/javascript" charset="utf-8" src="serverContext.js"></script> <script type="text/javascript" charset="utf-8" src="cordova.js"></script> <script> var applicationContext = null; var store = null; //Offline OData store var startTime = new Date(); var initTime = null; var unlockTime = null; var resumeTime = null; window.onerror = onError; function onError(msg, url, line) { var idx = url.lastIndexOf("/"); var file = "unknown"; if (idx > -1) { file = url.substring(idx + 1); } alert("An error occurred in " + file + " (at line # " + line + "): " + msg); return false; //suppressErrorAlert; } function init() { updateStatus2("Calling Logon.init"); initTime = new Date(); var endTime = new Date(); var duration = (endTime - startTime)/1000; console.log("EventLogging: Time from onload to deviceready " + duration + " seconds"); if (sap.Logger) { sap.Logger.setLogLevel(sap.Logger.DEBUG); //enables the display of debug log messages from the Kapsel plugins. sap.Logger.debug("EventLogging: Log level set to DEBUG"); } if (navigator.notification) { // Override default HTML alert with native dialog. alert is not supported on Windows window.alert = navigator.notification.alert; } sap.Logon.init(logonSuccessCallback, logonErrorCallback, appId, context); console.log("EventLogging: init completed"); } function logonSuccessCallback(result) { updateStatus2("logonSuccessCallback called"); var endTime = new Date(); if (unlockTime) { var duration = (endTime - unlockTime)/1000; console.log("EventLogging: Unlock Time " + duration + " seconds"); unlockTime = null; } applicationContext = result; showScreen("MainDiv"); } function logonErrorCallback(error) { //this method is called if the user cancels the registration. alert("An error occurred: " + JSON.stringify(error)); if (device.platform == "Android") { //Not supported on iOS navigator.app.exitApp(); } } function readProducts(isLocal, inErrorState) { showScreen("MainDiv"); updateStatus2("Read request started"); startTime = new Date(); if (!applicationContext) { alert("Register or unlock before proceeding"); return; } clearTable(); var sUrl = applicationContext.applicationEndpointURL + "/Products?$format=json&$orderby=Category,ProductId"; //JSON format is less verbose than atom/xml if (isLocal === true) { if (!store) { updateStatus2("The store must be opened before reading local entities."); return; } sUrl = sUrl + "&$filter=sap.islocal()"; } if (inErrorState === true) { if (!store) { updateStatus2("The store must be opened before reading error entities."); return; } sUrl = sUrl + "&$filter=sap.inerrorstate()"; } var oHeaders = {}; //oHeaders['X-SMP-APPCID'] = applicationContext.applicationConnectionId; //not needed as this will be sent by the logon plugin var request = { headers : oHeaders, requestUri : sUrl, method : "GET" }; if (device.platform == "windows") { //provided by the authproxy and logon plugins on Android and iOS but not on Windows https://support.wdf.sap.corp/sap/support/message/1680272744 request.user = applicationContext.registrationContext.user; request.password = applicationContext.registrationContext.password; } OData.read(request, readSuccessCallback, errorCallback); } function createProducts() { var updateForm = document.forms["CreateForm"]; updateForm.ID.value = ""; updateForm.prodName.value = ""; updateForm.currency.value = ""; updateForm.description.value = ""; updateForm.price.value = ""; showScreen("CreateDiv"); } function readSuccessCallback(data, response) { var endTime = new Date(); var duration = (endTime - startTime)/1000; updateStatus2("Read " + data.results.length + " records in " + duration + " seconds"); var productsTable = document.getElementById("productsTable"); for (var i = data.results.length -1; i >= 0; i--) { var row = productsTable.insertRow(1); var dresult = data.results[i]; applyColor(data.results[i], row); var cell1 = row.insertCell(0); var cell2 = row.insertCell(1); var cell3 = row.insertCell(2); var cell4 = row.insertCell(3); var cell5 = row.insertCell(4); var productID = data.results[i].ProductId cell1.innerHTML = data.results[i].ProductId; cell2.innerHTML = '<a href="javascript:void(0)" onclick="showProductUpdateScreen(\'' + productID + '\')">' + data.results[i].Name + '</a>'; cell3.innerHTML = data.results[i].ShortDescription; cell4.innerHTML = Math.round(data.results[i].Price * 100) / 100; cell5.innerHTML = data.results[i].CategoryName; } } function applyColor(data, row) { if (data["@com.sap.vocabularies.Offline.v1.isLocal"]) { row.style.color = "green"; } if (data["@com.sap.vocabularies.Offline.v1.inErrorState"]) { row.style.color = "red"; } if (data["@com.sap.vocabularies.Offline.v1.isDeleteError"]) { row.style.color = "orange"; } } function showProductUpdateScreen(productID) { updateStatus2("Read product request started"); startTime = new Date(); showScreen("UpdateDiv"); var sUrl = applicationContext.applicationEndpointURL + "/Products('" + productID + "')"; //HTTP headers var oHeaders = {}; //HTTP Request var request = { headers : oHeaders, requestUri : sUrl, method : "GET" }; OData.read(request, readProductItemSuccessCallback, errorCallback); } function readProductItemSuccessCallback(data, response) { var endTime = new Date(); var duration = (endTime - startTime)/1000; updateStatus2("Read item in " + duration + " seconds"); var updateForm = document.forms["UpdateForm"]; //Add Properties that should be updated updateForm.ID.value = data.ProductId; updateForm.prodName.value = data.Name; updateForm.description.value = data.ShortDescription; updateForm.currency.value = data.CurrencyCode; updateForm.price.value = Math.round(data.Price * 100) / 100; } function createRecord() { var updateForm = document.forms["CreateForm"]; var updateProductsURL = applicationContext.applicationEndpointURL + "/Products"; var oHeaders = {}; var params = {}; //The below values will be set when we issue a patch params.ProductId = updateForm.ID.value; /*params.Name = updateForm.prodName.value; params.CurrencyCode = updateForm.currency.value; params.ShortDescription = updateForm.description.value; params.Price = updateForm.price.value;*/ //HTTP Headers /*if (csrf) { oHeaders['X-CSRF-TOKEN'] = csrf; }*/ oHeaders['Content-Type'] = "application/json"; oHeaders['accept'] = "application/json"; oHeaders['If-Match'] = "*"; //Some services needs If-Match Header for Update/delete var request = { headers : oHeaders, requestUri : updateProductsURL, method : "POST", data : params }; //Since Products has a hasStream = "true", the initial create or Post succeeds but the values are not null, need to do a Patch to set the values. OData.request(request, patchEntity, errorCallback); } function patchEntity(data, response) { var localOfflinePrimaryKey = data.__metadata.etag; var updateForm = document.forms["CreateForm"]; var updateProductsURL = data.__metadata.uri; //applicationContext.applicationEndpointURL + "/Products"; var oHeaders = {}; var params = {}; //params.ProductId = updateForm.ID.value; params.Name = updateForm.prodName.value; params.CurrencyCode = updateForm.currency.value; params.ShortDescription = updateForm.description.value; params.Price = updateForm.price.value; //HTTP Headers /*if (csrf) { oHeaders['X-CSRF-TOKEN'] = csrf; }*/ oHeaders['Content-Type'] = "application/json"; oHeaders['accept'] = "application/json"; oHeaders['If-Match'] = "*"; //Some services needs If-Match Header for Update/delete var request = { headers : oHeaders, requestUri : updateProductsURL, method : "PATCH", data : params }; OData.request(request, readProducts, errorCallback); } function updateRecord() { var updateForm = document.forms["UpdateForm"]; var updateProductsURL = applicationContext.applicationEndpointURL + "/Products('" + updateForm.ID.value + "')"; var oHeaders = {}; var params = {}; params.Name = updateForm.prodName.value; params.CurrencyCode = updateForm.currency.value; params.ShortDescription = updateForm.description.value; params.Price = updateForm.price.value; //HTTP Headers /*if (csrf) { oHeaders['X-CSRF-TOKEN'] = csrf; }*/ oHeaders['Content-Type'] = "application/json"; oHeaders['accept'] = "application/json"; oHeaders['If-Match'] = "*"; //Some services needs If-Match Header for Update/delete var request = { headers : oHeaders, requestUri : updateProductsURL, method : "PUT", //MERGE/PATCH/PUT data : params }; OData.request(request, readProducts, errorCallback); } function deleteRecord() { var updateForm = document.forms["UpdateForm"]; var deleteStockURL = applicationContext.applicationEndpointURL + "/Stock('" + updateForm.ID.value + "')"; var deleteProductsURL = applicationContext.applicationEndpointURL + "/Products('" + updateForm.ID.value + "')"; var oHeaders = {}; var params = {}; oHeaders['Content-Type'] = "application/json"; oHeaders['accept'] = "application/json"; oHeaders['If-Match'] = "*"; //Some services needs If-Match Header for Update/delete var request = { headers : oHeaders, requestUri : deleteStockURL, method : "DELETE", //data : params }; OData.request(request, function() {console.log("Delete stock " + updateForm.ID.value + " succeeded.")}, function(e) {console.log("Delete Request failed: " + JSON.stringify(e))}); //might not be any stock entries request.requestUri = deleteProductsURL; OData.request(request, readProducts, errorCallback); } function clearTable2(tableId) { var productsTable = document.getElementById(tableId); while(productsTable.rows.length > 1) { productsTable.deleteRow(1); } } function clearTable() { var productsTable = document.getElementById("productsTable"); while(productsTable.rows.length > 1) { productsTable.deleteRow(1); } } function errorCallback(e) { alert("An error occurred: " + JSON.stringify(e)); console.log("EventLogging: errorCallback " + JSON.stringify(e)); showScreen("MainDiv"); } function register() { updateStatus2("Calling Logon.init"); sap.Logon.init(logonSuccessCallback, logonErrorCallback, appId, context); } function unRegister() { updateStatus2("Calling deleteRegistration"); sap.Logon.core.deleteRegistration(logonUnregisterSuccessCallback, errorCallback); clearTable(); } function logonUnregisterSuccessCallback(result) { updateStatus2("Successfully Unregistered"); console.log("EventLogging: logonUnregisterSuccessCallback " + JSON.stringify(result)); applicationContext = null; register(); } function lock() { sap.Logon.lock(logonLockSuccessCallback, errorCallback); clearTable(); } function logonLockSuccessCallback(result) { console.log("EventLogging: logonLockSuccessCallback " + JSON.stringify(result)); applicationContext = null; showScreen("LockedDiv"); //sap.Logon.unlock(function () {},function (error) {}); //alternatively show the unlock screen } function unlock() { unlockTime = new Date(); sap.Logon.unlock(logonSuccessCallback, errorCallback); } function managePasscode() { sap.Logon.managePasscode(managePasscodeSuccessCallback, errorCallback); } function managePasscodeSuccessCallback() { console.log("EventLogging: managePasscodeSuccess"); } function showScreen(screenIDToShow) { var screenToShow = document.getElementById(screenIDToShow); screenToShow.style.display = "block"; var screens = document.getElementsByClassName('screenDiv'); for (var i = 0; i < screens.length; i++) { if (screens[i].id != screenToShow.id) { screens[i].style.display = "none"; } } } function openStore() { console.log("EventLogging: openStore"); startTime = new Date(); updateStatus2("store.open called"); var properties = { "name": "CarrierOfflineStore", "host": applicationContext.registrationContext.serverHost, "port": applicationContext.registrationContext.serverPort, "https": applicationContext.registrationContext.https, "serviceRoot" : appId, //"storePath" : cordova.file.externalRootDirectory, "definingRequests" : { "ProductsDR" : "/Products", "StockDR" : "/Stock" } }; if (device.platform == "windows") { var authStr = "Basic " + btoa(applicationContext.registrationContext.user + ":" + applicationContext.registrationContext.password); properties.streamParams = "custom_header=Authorization:" + authStr; } store = sap.OData.createOfflineStore(properties); store.onrequesterror = onRequestError; //called for each modification error during flush var options = {}; store.open(openStoreSuccessCallback, errorCallback, options, progressCallback); } function onRequestError(error) { navigator.notification.alert("An error occurred while FLUSHING " + JSON.stringify(error)); console.log("An error occurred while FLUSHING " + JSON.stringify(error)); } function openStoreSuccessCallback() { var endTime = new Date(); var duration = (endTime - startTime)/1000; updateStatus2("Store opened in " + duration + " seconds"); updateStatus1("Store is OPEN."); console.log("EventLogging: openStoreSuccessCallback. Stored opened in " + duration); sap.OData.applyHttpClient(); //Offline OData calls can now be made against datajs. } function closeStore() { if (!store) { updateStatus2("The store must be opened before it can be closed"); return; } console.log("EventLogging: closeStore"); updateStatus2("store.close called"); store.close(closeStoreSuccessCallback, errorCallback); } function closeStoreSuccessCallback() { console.log("EventLogging: closeStoreSuccessCallback"); sap.OData.removeHttpClient(); updateStatus1("Store is CLOSED."); updateStatus2("Store closed"); } //Removes the physical store from the filesystem function clearStore() { console.log("EventLogging: clearStore"); if (!store) { updateStatus2("The store must be closed before it can be cleared"); return; } store.clear(clearStoreSuccessCallback, errorCallback); } function clearStoreSuccessCallback() { console.log("EventLogging: clearStoreSuccessCallback"); updateStatus1(""); updateStatus2("Store is CLEARED"); store = null; } function refreshStore() { console.log("EventLogging: refreshStore"); if (!store) { updateStatus2("The store must be open before it can be refreshed"); return; } startTime = new Date(); updateStatus2("Store refresh called"); store.refresh(refreshStoreCallback, errorCallback, null, progressCallback); } function refreshStoreCallback() { console.log("EventLogging: refreshStoreCallback"); var endTime = new Date(); var duration = (endTime - startTime)/1000; updateStatus2("Store refreshed in " + duration + " seconds"); } function flushStore() { console.log("EventLogging: flushStore"); if (!store) { updateStatus2("The store must be open before it can be flushed"); return; } startTime = new Date(); updateStatus2("Store flush called"); store.flush(flushStoreSuccessCallback, errorCallback, null, progressCallback); } function flushStoreSuccessCallback() { console.log("EventLogging: flushStoreSuccessCallback"); var endTime = new Date(); var duration = (endTime - startTime)/1000; updateStatus2("Store flushed in " + duration + " seconds"); refreshStore(); } function viewLog() { if (sap.Logger) { sap.Logger.getLogEntries(getLogEntriesSuccess, errorCallback) } else { alert("ensure the kapsel-logger plugin has been added to the project"); } } function getLogEntriesSuccess(logEntries) { var stringToShow = ""; var logArray; if (device.platform == "windows") { logArray = logEntries.split("\n"); if (logArray.length > 0) { for (var i = 0; i < logArray.length; i++) { stringToShow += logArray[i] + "\n"; } } } else if (device.platform == "iOS") { logArray = logEntries.split("\n"); if (logArray.length > 0) { for (var i = 0; i < logArray.length; i++) { logLineEntries = logArray[i].split(" "); for (var j = 7; j < logLineEntries.length; j++) { stringToShow += logLineEntries[j] + " "; } stringToShow = stringToShow + "\n"; } } } else { //Android logArray = logEntries.split('#'); if (logArray.length > 0) { var numOfMessages = parseInt(logArray.length / 15); for (var i = 0; i < numOfMessages; i++) { stringToShow += logArray[i * 15 + 1] + ": " + logArray[i * 15 + 3] + ": " + logArray[i * 15 + 14] + "\n"; } } } alert(stringToShow); console.log("EventLogging: Device Log follows " + stringToShow); } function updateStatus1(msg) { document.getElementById('statusID').innerHTML = msg + " " + getDeviceStatusString(); console.log("EventLogging: " + msg + " " + getDeviceStatusString()); } function updateStatus2(msg) { var d = new Date(); document.getElementById('statusID2').innerHTML = msg + " at " + addZero(d.getHours()) + ":" + addZero(d.getMinutes()) + "." + addZero(d.getSeconds()); if (msg.indexOf("Refreshing Sent:") != 0) { //don't bother logging the messages regarding the progress callback for flush and refresh console.log("EventLogging: " + msg + " at " + addZero(d.getHours()) + ":" + addZero(d.getMinutes()) + "." + addZero(d.getSeconds())); } } function addZero(input) { if (input < 10) { return "0" + input; } return input; } function getDeviceStatusString() { if (online) { return "Device is ONLINE"; } else { return "Device is OFFLINE"; } } function progressCallback(progressStatus) { var status = progressStatus.progressState; var lead = "unknown"; if (status === sap.OfflineStore.ProgressState.STORE_DOWNLOADING) { lead = "Downloading "; } else if (status === sap.OfflineStore.ProgressState.REFRESH) { lead = "Refreshing "; } else if (status === sap.OfflineStore.ProgressState.FLUSH_REQUEST_QUEUE) { lead = "Flushing "; } else if (status === sap.OfflineStore.ProgressState.DONE) { lead = "Complete "; } else { alert("Unknown status in progressCallback"); } updateStatus2(lead + "Sent: " + progressStatus.bytesSent + " Received: " + progressStatus.bytesRecv + " File Size: " + progressStatus.fileSize); } function deviceOnline() { online = true; updateStatus1(""); } function deviceOffline() { online = false; updateStatus1(""); } function onLoad() { console.log("EventLogging: onLoad"); } function onBeforeUnload() { console.log("EventLogging: onBeforeUnLoad"); } function onUnload() { console.log("EventLogging: onUnload"); } function onPause() { console.log("EventLogging: onPause"); } function onResume() { resumeTime = new Date(); console.log("EventLogging: onResume"); } function onSapResumeSuccess() { console.log("EventLogging: onSapResumeSuccess"); var endTime = new Date(); var duration = (endTime - resumeTime)/1000; console.log("EventLogging: Time from onresume to onSapResumeSuccess " + duration + " seconds"); } function onSapLogonSuccess() { console.log("EventLogging: onSapLogonSuccess"); } function onSapResumeError(error) { console.log("EventLogging: onSapResumeError " + JSON.stringify(error)); } function showErrorsSuccessCallback(data, response) { updateStatus2("ErrorArchive contains " + data.results.length + " records "); console.log(JSON.stringify(data.results)); clearTable2("ErrorsTable"); showScreen("ErrorsDiv"); var errorsForm = document.getElementById("ErrorsForm"); errorsForm.setAttribute("requestid", ""); var errorsTable = document.getElementById("ErrorsTable"); for (var i = 0; i < data.results.length; i++) { var row = errorsTable.insertRow(1); var product = JSON.parse(data.results[i].RequestBody); var cell1 = row.insertCell(0); var cell2 = row.insertCell(1); var cell3 = row.insertCell(2); var cell4 = row.insertCell(3); var cell5 = row.insertCell(4); var cell6 = row.insertCell(5); if (i == 0) { //any requestID will work in the delete call to the ErrorArchive errorsForm.setAttribute("requestid", data.results[i].RequestID); } cell1.innerHTML = data.results[i].RequestMethod; cell2.innerHTML = data.results[i].HTTPStatusCode; if (product) { cell3.innerHTML = product.Name; cell4.innerHTML = product.Price; } cell5.innerHTML = data.results[i].RequestURL; cell6.innerHTML = data.results[i].Message; } } //Uses the Error Archive. You can also view rows that are in an error state using a filter or by looking for an annotation. See the read() method function showErrors() { if (!store) { updateStatus2("The store must be opened before viewing the ErrorArchive"); return; } updateStatus2("ErrorArchive request started"); clearTable2("ErrorsTable"); var sURL = applicationContext.applicationEndpointURL + "/ErrorArchive"; var oHeaders = {}; var request = { headers : oHeaders, requestUri : sURL, method : "GET" }; console.log("read using " + sURL); OData.read(request, showErrorsSuccessCallback, errorCallback); } function clearErrors() { var requestID = document.forms["ErrorsForm"].getAttribute("requestid"); if (requestID == "") { alert("There are no errors to remove from the ErrorArchive"); return; } navigator.notification.confirm( "Proceeding will revert all operations that are currently in an error state", // message clearErrors2, // callback to invoke with index of button pressed "Warning", // title ["Continue","Cancel"] // buttonLabels ); } function clearErrors2(buttonIndex) { if (buttonIndex == 2) { return } console.log("Clearing error!") updateStatus2("Clear ErrorArchive entry started"); var requestID = document.forms["ErrorsForm"].getAttribute("requestid"); var deleteErrorsURL = applicationContext.applicationEndpointURL + "/ErrorArchive(" + requestID + ")"; var oHeaders = {}; oHeaders['Content-Type'] = "application/json"; oHeaders['accept'] = "application/json"; oHeaders['If-Match'] = "*"; //Some services needs If-Match Header for Update/delete var request = { headers : oHeaders, requestUri : deleteErrorsURL, method : "DELETE" }; console.log(deleteErrorsURL); OData.request(request, showErrors, errorCallback); } function checkRequestQueue() { if (!store) { updateStatus2("The store must be opened before checking the request queue"); return; } store.getRequestQueueStatus(requestQSuccessCallback, errorCallback) } function requestQSuccessCallback(qStatus) { var statusStr = " contains items to be flushed"; if (qStatus.isEmpty) { statusStr = " is empty"; } updateStatus2("Request queue" + statusStr); } document.addEventListener("deviceready", init, false); document.addEventListener("pause", onPause, false); document.addEventListener("resume", onResume, false); document.addEventListener("online", deviceOnline, false); document.addEventListener("offline", deviceOffline, false); document.addEventListener("onSapResumeSuccess", onSapResumeSuccess, false); document.addEventListener("onSapLogonSuccess", onSapLogonSuccess, false); document.addEventListener("onSapResumeError", onSapResumeError, false); </script> </head> <body onload="onLoad()" onunload="onUnload()" onbeforeunload="onBeforeUnload()"> <div class="screenDiv" id="LoadingDiv"> <h1>Loading ...</h1> </div> <div class="screenDiv" id="LockedDiv" style="display: none"> <h1>Locked</h1> <button id="unlock2" onclick="unlock()">Unlock</button> </div> <div class="screenDiv" id="UpdateDiv" style="display: none"> <h3>Update</h3> <form id="UpdateForm"> ProductID: <input type="text" name="ID" ><br> Name: <input type="text" name="prodName" ><br> Currency: <input type="text" name="currency" ><br> Description: <input type="text" name="description" ><br> Price: <input type="text" name="price"><br> <button type=button onclick="updateRecord()">Update</button> <button type=button onclick="deleteRecord()">Delete</button> <button type=button onclick="showScreen('MainDiv')">Back</button> </form> </div> <div class="screenDiv" id="CreateDiv" style="display: none"> <h3>Create</h3> <form id="CreateForm"> ProductID: <input type="text" name="ID" ><br> Name: <input type="text" name="prodName" ><br> Currency: <input type="text" name="currency" ><br> Description: <input type="text" name="description" ><br> Price: <input type="text" name="price"><br> <button type=button onclick="createRecord()">Save</button> <button type=button onclick="showScreen('MainDiv')">Back</button> </form> </div> <div class="screenDiv" id="ErrorsDiv" style="display: none"> <h3>Errors</h3> <form id="ErrorsForm"> <table id="ErrorsTable"> <tr> <th align="left">Op</th> <th align="left">Code</th> <th align="left">Name</th> <th align="left">Price</th> <th align="left">URL</th> <th align="left">Message</th> </tr> </table> </form> <button id="clearError" onclick="clearErrors()">Clear Errors</button> <button type=button onclick="showScreen('MainDiv')">Back</button> </div> <div class="screenDiv" id="MainDiv" style="display: none"> <h1>Offline Sample 2</h1> <button id="unregister" onclick="unRegister()">Unregister</button> <button id="read" onclick="readProducts()">Read</button> <button id="createItem" onclick="createProducts()">Create</button> <button id="clearLog" onclick="sap.Logger.clearLog();updateStatus2('Cleared the Log')">Clear Log</button> <button id="viewLog" onclick="viewLog()">View Log</button><br> <button id="openStore" onclick="setTimeout(openStore, 10);">Open Offline Store</button> <button id="closeStore" onclick="closeStore()">Close Offline Store</button> <button id="clearStore" onclick="clearStore()">Clear Offline Store</button><br> <button id="readlocal" onclick="readProducts(true)">Read Local</button> <button id="read3" onclick="readProducts(false, true)">Filter on Errors</button> <button id="sync" onclick="flushStore()">Flush and Refresh</button> <button id="showErrors" onclick="showErrors()">Error Archive</button> <button id="requestQueue" onclick="checkRequestQueue()">Request Queue</button><br> <span id="statusID"></span><br> <span id="statusID2"></span> <table id="productsTable"><tr><th align='left'>ID</th><th align='left'>Name</th><th align='left'>Description</th><th align='left'>Price</th><th align='left'>Category</th></tr></table> </div> </body> </html>
- Copy the serverContext.js file from C:\Kapsel_Projects\KapselGSDemo\www to C:\Kapsel_Projects\KapselGSDemo2\www and ensure that the host, port and https values are correct. The port should be 443 and https should be set to true.
Note the password value will be the same value used to open the SAP Cloud Platform Mobile Services management cockpit.
Finally, modify the appId to be com.kapsel.gs2. - Place a copy of datajs-1.1.2.min.js into the www folder.
- Modify the config.xml file to add any required settings such as the android-minSdkVersion and windows-target-version.
- Prepare, build and deploy the app with the following command.
cordova run android or cordova run windows --archs=x86 or cordova run ios
Note products can now be added to the list, removed or modified. This can occur when the device is online or if it is offline, then when the device is online and the Flush button is pressed, the changes are sent to the OData producer.
- Changes to entities that have not yet been flushed to the OData producer appear in green.
Pressing the Read Local button adds the following filter to only show these rows.$filter=sap.islocal()
Error Conditions
For additional details see Error Handling.
For each error that occurs while flushing the changes to the backend OData source, the onRequestError method is called.
As well, the rows affected by a failed flush operation are annotated to indicate that they are in an errorstate. The following is an example of an annotation that appears in a record that is in an error state.
{"uri":"https://hcpms-i826567trial.hanatrial.ondemand.com:443/com.kapsel.gs2/Products('HT-101')", "type":"ESPM.Product"}, "@com.sap.vocabularies.Offline.v1.isLocal":true, "@com.sap.vocabularies.Offline.v1.inErrorState":true, "Name":"USB Battery" ...
The read method in index.html calls the applyColor method and searches for three annotations, islocal, inErrorState and isDeleteError and applies the colors green, red, and orange.
The below filter is applied after pressing the Filter on Errors button which limits the results of the read to only include rows that are in an error state.
&$filter=sap.inerrorstate()
Finally, there is an error archive that can be queried which provides additional details of errors that occurred during a flush operation.
Deleting an entry from the Error Archive removes all rows from the Error Archive and reverts all rows that were in an error state to their previous state. The following steps can be followed to further illustrate some error conditions.
Flush Fails due to No Network
The error callback method will be called with the an error like the following.
[-10208] Communication with the server because the host could not be found.
Update Fails in the Backend Due to a Backend Constraint
This type of error can be caught by the onRequestError listener of the store.
store.onrequesterror = onRequestError; //called for each modification error during flush
[-10197] A request failed against the backen OData producer. The value of quantity should be greater than zero.
This may occur if constraint on the backend OData service exists that does not exist on the local offline OData store.
To recover, perform another update with a correct value, flush and refresh or in the Error Archive click on Clear Errors to revert the change.
Attempting to Delete or Update a Non Existent Row
With the offline store open, create a new product. The row appears as green as it has not yet been flushed.
Perform a flush and refresh.
Close the offline store.
Perform a read. Notice the new row exists. Delete the row.
Open the offline store.
Perform a read. Notice that the deleted row still exists in the offline store.
Delete or Update the row.
Flush the offline store which will cause an error as the delete or update request will fail.
[-10197] A request failed against the backend OData producer. Requested entity could not be found.
Perform a read and notice that the row now appears in orange to indicate that there was a problem deleting this row or in red to indicate the row is in an error state.
Click on Check Error Archive to view the contents of the Error Archive. Notice that the Operation was a DELETE request and the Error Code was 404 which indicates that it was unable to delete the entity.
Click on Error Archive.
To correct the problem, close the store, delete the store and reopen the store.
Auto Increment Primary Keys
The previous example requires specifying the ID value for a product when creating a record. If two devices that are both offline specify the same ID value, one of them will fail when flushing the change to the backend OData producer.
One approach to avoiding this problem is to specify that the ID should be an auto increment field. In this way, the ID’s for each product are determined by the backend and the offline store will create a temporary ID. This temporary ID can be seen via the uri field of the __metadata of a successful create operation against the offline store or via a read operation against a product that has been added to an offline store but not yet flushed and refreshed. Here is an example showing this.
Notice above that the ID value is null as one was not specified during the create operation and that the temporary assigned ID as shown in the returned uri is http://10.7.171.158:8080/com.mycompany.offline2/DanProd(lodata_sys_eid=X’00737054815011E58000B36030BC526C00000000′). This URI should be used if any further operations will be performed on this local data.
Once the offline store is flushed and refreshed the value of ID will be updated to reflect the ID assigned from the backend.
Offline and Online in One App
The service root for the offline and always online requests must be different. In this sample requests that are made to
https://hcpms-XXXXtrial.hanatrial.ondemand.com/com.kapsel.gs2/Products
are redirected to the Offline Store when it is open and requests to
https://hcpms-i826567trial.hanatrial.ondemand.com/com.kapsel.gs3/Products
are not redirected to the offline store. The service root is specified when the store is initially opened.
function openStore() { ... var properties = { "name": "CarrierOfflineStore", "host": applicationContext.registrationContext.serverHost, "port": applicationContext.registrationContext.serverPort, "https": applicationContext.registrationContext.https, "serviceRoot" : appId, "definingRequests" : { "ProductsDR" : "/Products", "StockDR" : "/Stock" } }; store = sap.OData.createOfflineStore(properties); //var options = {}; store.open(openStoreSuccessCallback, errorCallback/*, options*/); }
The following steps demonstrate this using the previous project.
Note, the techniques used in this sample are for learning purposes only. In a non-demo app, it is recommended that the offline store be used all the time to maintain consistency of the data.
- Add a second back-end connection to the application com.kapsel.gs2 with a name of com.kapsel.gs3 in the management cockpit that also points to https://hcpms-XXXXtrial.hanatrial.ondemand.com/SampleServices/ESPM.svc.
- Modify www/index.html and a second read button.
<button id="read2" onclick="readProducts2()">Read Online</button><br>
- Copy and paste the readProducts method and rename the copied method to be readProducts2
- Modify the readProducts2 method. Add this line just after sUrl is defined.
sUrl = sUrl.replace("gs2", "gs3"); //make the read request against the backend rather than the offline store.
- Prepare, build and deploy the app with the following command.
cordova run android or cordova run windows --archs=x64 or cordova run ios
Open the Offline store. Press the Read button and notice the duration the read took. Now press the Read Online button and notice that it takes longer as the read is going to the back-end rather than being retrieved from the offline store.
Questions and Answers
Question
Is there any way to add additional “defining Request” or modify an existing one after an OData offline database has been created?
There is a product collection with 1 million entries. I do not want to download this complete collection, so I am setting my definingrequest to an OData collection with filter to city eq Berlin. This is working and will create the database for me with a few thousand products. Now I am in another city and want to add the products of that other city, e.g. Hamburg into my already existing offlineDB. Is this somehow possible?
Answer
There is no way to add or modify defining requests in an already existing database in SP11. Once a database is created, the defining requests become fixed.
One potential way to work around this is to create multiple stores on your device. By using different storeName values in the store options, you can have multiple stores existing at the same time.
So you can have a store_Hamburg and a store_Berlin and open either depending on where you are.
Question
Is there any way to perform a free text search on the offline database?
I know that in OData V4 a new $search query parameter got introduced, but I guess this one is not yet available in the offline store.
Answer
The offline store does not yet support the $search query parameter.
However, it does support the $filter indexof operation. With this, you can do searches such as:
/Products?$filter=indexof(Name, 'wrench') ge 0
to search for entities containing a string
Question
I know that function imports are not supported in offline. But if there are entities with function imports and without function imports, can we use entities without function imports offline?
Also, what about in the case of variables with size more than 512? If any of the collection having variable size more than 512 can it be used when offline?
Answer
We essentially ignore function imports. So as long as you don’t try to use them, you can offline other entity sets fine.
We currently don’t support KEY properties or REFERENTIAL CONSTRAINT properties with a greater size. However, all other properties can have a greater size.
Note that the maxiumum size of Edm.String key and Edm.binary key properties increased in SP 11. See Offline OData Version Support and Limitations.
Question
I am getting an error when opening my offline store such as
Provided value for the property 'Price' is not compatible with the property
Answer
The V2 spec requires that Edm.Double be represented as a JSON string, but this service is returning them as a JSON floating point number. The parser in the offline server is strict and expects a string so it is failing when it is parsing the Edm.Double properties.
A work around to this issue is to force the offline server to use ATOM when retrieving the defining requests. That can be done by configuring the offline application using an INI file and using that under Applications > com.mycompany.offline > Import Settings.
Name=com.mycompany.offline5 request_format=atom
Question
It seems that the OData Offline DB uses a case sensitive search. My customer would like to get results case insensitive.
Answer
The case sensitivity is based on the case sensitivity of the underlying database which is case sensitive by default. Comparison operations can be made case-insensitive using an application config ini file and setting
case_sensitive_offline_db=no
However, using substringof with tolower should work fine. Here is an example of using the tolower method in a filter to avoid case sensitivity.
http://services.odata.org/V2/OData/OData.svc/Products?$format=json&$filter=tolower(Name) eq 'bread' vs http://services.odata.org/V2/OData/OData.svc/Products?$format=json&$filter=Name eq 'bread'
Question
How many Offline Stores can be open at the same time? The following error is occurring when opening five offline databases. [-10067] Could not create the store. Reason: -685
Answer
There is a limit to the total number of Ultralite databases that can be running concurrently as of SP09, and each offline store has 2 databases (the offline store and the request queue). Currently on a device, the limit is 8 databases which means that a maximum of 4 offline stores can be open at the same time.
Question
The following error is seen
09-08 13:12:29.466: I/chromium(13600): [INFO:CONSOLE (1059)] “processMessage failed: Stack: RangeError: Invalid time value
09-08 13:12:29.466: I/chromium(13600): at Date.toISOString (native)
09-08 13:12:29.466: I/chromium(13600): at sap.ui.controller._stringToUTC
Answer
After calling applyHttpClient try adding this line: window.OData.jsonHandler.recognizeDates = true;
Question
Is there support for sync/flush operations on the Offline DB when the app is in the background?
Answer
See the topic Background Synchronization.
Question
When the offline store opens, the following error is seen.
The conversion from OData metadata to database metadata has failed
In the SMP server log the following is seen.
Property "GenderCode" cannot be supported because it is a key or referential constraint property and its MaxLength facet is missing or has a value that is too large
Answer
A setting was added to SMP 3.0 SP10 that enables this to be worked around if it is not possible to specify a MaxLength for the service being used. See the property named allow_omitting_max_length_facet described in Application Configuration File.
Question
I would like to selectively flush records. For example, some records may be partial records that require additional data entry on the device before they are synced.
Answer
There currently is not support for selectively flushing records. Currently this would need to be handled by the application by storing incomplete records in a different entity set that is not part of the defining requests.
Question
When we create an entity locally, and the entity uses a server side generated key, then we only have the “lodata_sys_eid” as a reliable form of identification of the entity? After a flush and refresh cycle … how do we find the actual “real key” from the “lodata_sys_eid”?
An application creates an entity while offline, and after the create takes place the entity is shown to the user … now the app flushes and refreshes in the background …. now the user wants to edit the just created entity … does the entity still exist?
Answer
You can still use the generated entity ID after a flush/refresh cycle for “a while”. The last 500 generated entity IDs are kept after a flush/refresh cycle for this purpose.
Hello Daniel,
is there a way to build some proper Error handling on client side? For example a diff screen (current data online vs. data stored locally on the system). Currently I can't find API solutions to really solve errors -- only deletion of the error log seems possible.
Regards,
Kim Lu
Hello Daniel,
When i try to remove errors from the /ErrorArchive i keep getting the following error:
[-10163] Permission to update \"Service_Entities\" denied.
Does anyone have the same problem? or any idea for a solution?
Kind regards,
Karel Verbanck
I have not myself seen that error.
Is the error occurring with the example from this blog post?
What version of the SDK are you using?
What platform and version?
Have you tried using the Web Inspector to examine the delete request?
It might also help to increase the log level and look through the device logs.
Regards,
Dan van Leeuwen
Hi Daniel,
I didn't try the example from this blog post. I will give it a try later on.
We are on SDK 14 PL 15 and using an iPad running iOS 10.3.2
and the app is published on the SMP (for app update reasons) (version of the SMP 3.0.12.3)
The sample worked for me on Android and iOS. I used the 3.15.2 version of the Kapsel SDK. Here is how the delete request looks for me.
Object {headers: Object, requestUri: "https://hcpms-i82xxxtrial.hanatrial.ondemand.com:443/com.kapsel.gs2/ErrorArchive(3)", method: "DELETE"}
Regards,
Dan van Leeuwen
Hi Karel,
I am getting the same error.
We are on Kapsel 3.15, iPhone running on iOS 10.3.2
When i try to remove errors from the /ErrorArchive i keep getting the following error:
[-10163] Permission to update \”Service_Entities\” denied.
I have raised a question regarding the same at:
https://answers.sap.com/questions/272719/sap-offline-app-unable-to-delete-error-records-per.html
Did you resolve the error? Thanks,
-Sai
Hi Sai,
I've upgraded to the latest patch SP15 pl7. (installed the latest hat locally and upgraded cordova, smp sdk, ...)
But now I'm getting another error message (deleting from the errorarchive in a batch request is not supported)
If i find a solution i will let you know.
Kind regards,
Karel
Hi Sai,
If you then set the that.devapp.appModel.setUseBatch (false); just before the remove, it should work.
Kind regards,
Karel
i have an issue with the create in the offline database. it creates the row but the fields are null. Like this:
"__metadata": {
"uri": "/CarrierCollection(lodata_sys_edt=X'F5B895CCA33A11E78000F63FBEB0047300000000')",
"type": "RMTSAMPLEFLIGHT.Carrier",
"etag": "W\/\"lodata_sys_etagf5b8e3ec-a33a-11e7-8000-f63fbeb00473\"",
"content_type": "application/json",
"media_src": "/CarrierCollection(lodata_sys_eid=X'F5B895CCA33A11E78000F63FBEB0047300000000')/$value",
"edit_media": "/CarrierCollection(lodata_sys_edt=X'F5B895CCA33A11E78000F63FBEB0047300000000')/$value",
"media_etag": "W\/\"lodata_sys_etagf5b8e3ed-a33a-11e7-8000-f63fbeb00473\""
},
"@com.sap.vocabularies.Offline.v1.isLocal": true,
"@com.sap.vocabularies.Offline.v1.mediaIsOffline": true,
"carrid": null,
"CARRNAME": null,
"CURRCODE": null,
"URL": null,
"mimeType": null,
"carrierFlights": {
"__deferred": {
"uri": "/CarrierCollection(lodata_sys_edt=X'F5B895CCA33A11E78000F63FBEB0047300000000')/carrierFlights"
}
}
my createRecord code is this:
function createRecord() {function createRecord() { var updateForm = document.forms["CreateForm"]; var CreateProductsURL = applicationContext.applicationEndpointURL + "/CarrierCollection"; var oHeaders = {}; var params = {}; params.carrid = updateForm.carrid.value; params.CARRNAME = updateForm.CARRNAME.value; params.CURRCODE = updateForm.CURRCODE.value; params.URL = "http:\/\/www.airfrance.fr"; params.mimeType = "text\/html"; oHeaders['Content-Type'] = "application/json"; oHeaders['accept'] = "application/json"; oHeaders['If-Match'] = "*"; //Some services needs If-Match Header for Update/delete var request = { headers : oHeaders, requestUri : CreateProductsURL, method : "POST", data : params }; OData.request(request, readProducts, errorCallback); }
i'd preciate your help:
When i try to delete a record on the odata with the method AgencyTravel_DQ the response is "500 Error" but still deletes de record i'd preciate any response. thank you very much.
Is this related to an example in the blog? If not, it would be best to post as standalone question. It would also help to include more details.
Regards,
Dan van Leeuwen
Hi Daniel,
We are planing to implement completely offline master/detail application. Refresh action will be triggered by a user. Is it possible to create/display attachments (camera photo) in offline mode and flush them via user action.
There is simple use case:
Thanks in advance,
Pavel
I have not myself dealt much with offline media streams. I did seem some more info on this topic at
https://www.sap.com/documents/2016/08/f6fa859b-847c-0010-82c7-eda71af511fa.html (search for Offline Media Elements)
Regards,
Dan van Leeuwen
Thank you.
Regards,
Pavel
Hello Daniel,
We have the strange behavior that when we open our offline store the first time, the calls to our gateway server are duplicated for all our defining requests. Also for the services which are delta enabled, a delta request is sent to the backend after the normal expected request.
Does this ring a bell?
Is it related to the config on the SMP server?
regards
Sorry, I don't know if that is expected or not. Perhaps post a question outside of this blog or open a ticket with support if this is causing a problem.
Regards,
Dan van Leeuwen
Hi Peter,
Looks like we have the same issue on our end.
Were you able to resolve it?
Regards
Vaibhav Surana
Hi Vaibhav,
I've opened a ticket for this. I'll keep you informed.
Hello,
I have a hybrid application (cordova) and I’m using kapsel to connect to offline odata, at the moment all queries are fine, the problem is when new records are created. These records are not returned in the query, I have reviewed and in the answer of the odata I do not see the flag “@com.sap.vocabularies.Offline.v1.isLocal”, I make the query with the filter "islocal()" and it does not return any record . However, when I activate the internet and do the flush, the created record is synchronized with the server. The problem is that I need to be able to consult the new registry while the application is offline, do I have to do some specific configuration for the plugin to add the flags to the response? Or maybe I should configure something in the smp?
The offline configuration tab
[endpoint]
name=com.cnet.bdsfvl.test
allow_omitting_max_length_facet=Y
I should configure the defining requests?
Regards
Miguel Piza
Hello Daniel,
Did you ever tried this for Windows 10 with Visual Studio 2017?
I'm using Cordova 8.0.0, Kapsel SDK 3.1 and Visual Studio 2017
When I create my Cordova project and I add additional Cordova plugins, I can still run the application using Visual Studio.
When I add Kapsel plugins (for example: kapsel-plugin-logger) and I'll try to run the application, I've got following error messages
Severity Code Description Project File Line Suppression State
Error APPX1712 The .winmd file 'SAP.Logon.Core.winmd' contains duplicate type names. Type 'SAP.Logon.Core.Settings.ApplicationSettings' is already registered with the in-process server 'CLRHost.dll'. CordovaApp.Windows10 D:\Programs\Microsoft Visual Studio\2017\Community\MSBuild\Microsoft\VisualStudio\v15.0\AppxPackage\Microsoft.AppXPackage.Targets 2492
Is Visual Studio 2017 supported for Kapsel? Of is still version 2015 required?
Many thanks!
Regards,
Hans
I believe so. Visual Studio 2017 with the Kapsel SDK 3.1.
Which version of the Windows platform are you using? I believe 5.0.0 is recommended.
<<Cordova 8.0 should be used if using SMP 3.1 SDK (Windows requires cordova platform add windows@5.0.0
Regards,
Dan van Leeuwen
I was informed that the latest PL had a fix for a similar sounding issue. I would also recommend trying the latest available 3.1 PL.
Regards,
Dan van Leeuwen
Hi Daniel,
Thx!
I had first some issues with VS2017 and therefore I tried it with VS2015 but with the same result.
(But what now the issue was??? no idea)
I did some clean installs on my computer (new VS2017, Nodejs, Cordova, …) and finally I got something working
With the logon plugin, I’m able to get the logon screen in VS2017 but when I’m trying to connect to SCPms i’ve got a syntax error in the sap-ui-core.js file
'WWAHost.exe' (Script): Loaded 'Script Code (MSAppHost/3.0)'.
'WWAHost.exe' (Script): Loaded 'Script Code (WebView/3.0)'.
'WWAHost.exe' (Script): Loaded 'Sourcemap (ms-appx-web://poc2/www/resources/sap-ui-core.js)'.
Exception was thrown at line 2050, column 6836 in ms-appx-web://poc2/www/resources/sap-ui-core.js
0x800a139e - JavaScript runtime error: SyntaxError
Exception was thrown at line 2050, column 7076 in ms-appx-web://poc2/www/resources/sap-ui-core.js
0x800a139e - JavaScript runtime error: SyntaxError
SourceMap ms-appx-web://poc2/www/resources/sap/ui/core/library-preload.js.map read failed: The URI prefix is not recognized..SourceMap ms-appx-web://poc2/www/resources/sap/m/library-preload.js.map read failed: The URI prefix is not recognized..Exception was thrown at line 2050, column 3113 in ms-appx-web://poc2/www/resources/sap-ui-core.js
0x800a139e - JavaScript runtime error: SyntaxError
Exception was thrown at line 2050, column 8572 in ms-appx-web://poc2/www/resources/sap-ui-core.js
0x800a139e - JavaScript runtime error: SyntaxError
I don't have following errors when I connect my recourse directory to sapui5.hana.ondemand.com
But I alsways have following error afterwards (with local recourses and online resources)
The program '[15640] WWAHost.exe' has exited with code -1073741189 (0xc000027b).
And then the app crashes and closes
if you have any idea about this, always welcome!
Regards,
Hans
Hi Daniel,
I think my issue has someting to do with the Logon plugin…
When I test my app (with local resources, or online resources) it runs a UI5 app (without connection to SCPms) but when I test my logon plugin, after I click on “OK” for connect, the app crashes. If the connection settings are wrong, I’ll get an error and the app don’t crashes.
When my connection settings are correct (so hostname, https, port), despite my username & password is oké, the app will crash after I click “OK”.
The program '[17260] WWAHost.exe' has exited with code -1073741189 (0xc000027b).
Any thoughts on this?
Regards,
Hans
It might be best to open a ticket with support. I assume they will need a way to reproduce the problem, perhaps by having you include the project and providing credentials.
Regards,
Dan van Leeuwen
Hi Hans,
Hi @Daniel Van Leeuwen ,
I am utilizing the online HAT available through Full stack WebIDE. I have two queries, wherein I am finding difficult time in issue resolution.Can you please help me through. I am basically following your blog through, for achieving offline capability.
2. How can we debug the application, when running on the device?
Is there anyway I can debug the controller, hybrid.js files when the application is being run on the device? We are basically looking for iOS applications alone.
Regards,
Harsha
For the first issue, perhaps the URL being passed is incorrect. What error are you getting?
I would recommend following through the first two sections of this blog series which demonstrate how to to a registration with the server and then a retrieval of data.
Regarding debugging, the appendix B demonstrates how to debug a Cordova application.
https://blogs.sap.com/2017/01/03/appendix-b-debugging/#ios
Another tool that can be helpful is ILOData which enables you to query an offline store directly sort of like an interactive SQL tool except instead of SQL statements you use OData queries. I hope to publish a tutorial soon on this and will update this question with the link.
Regards,
Dan van Leeuwen
Hi Daniel Van Leeuwen ,
Many thanks for your time and the response.
For the first issue I am not getting any error, it simply goes back to error fail back method and with error as null. The problem is that there is no network trace that is available to check the URL. Can you please help, on exactly how the sURL should look like? At the moment we are able to push the data onto store and we want to read them while we are flushing it, for error handling and user experience purposes.
For the debugging purpose, we did try the approach which you suggested ( in fact we followed the same blog), everything looks similar but we are not able to see index.html/ web resources to put break point when we run the app on the iPad/iPhone.
just to confirm, we are building and running applications using HAT online and SAP Full Stack WebIDE. Any sort of help for the above two issues is highly appreciated.
Thanks in advance.
Regards,
Harsha
Here is the blog on using ILOData which lets you work directly with an offline store using a tool that accepts OData queries.
https://blogs.sap.com/2018/12/09/step-by-step-with-the-sap-cloud-platform-sdk-for-android-part-6c-using-ilodata/
Regards,
Dan van Leeuwen
Hi Daniel Van Leeuwen ,
Many thanks for your response and for the blog.
As informed earlier, we are using Online HAT through full stack WebIDE and we are not installing any of the components locally. Can you please suggest how to utilize this in our scenario?
Regards,
Harsha
ILOData is a standalone tool that is included in the download for the SAP Mobile Platform SDK or the SAP Cloud Platform SDK for Android or iOS. It would be helpful in executing OData queries directly against the offline store without the need for your application.
I am less familiar with using the Web IDE and HAT. I assume though there is an option to generate a debuggable version of the resultant app vs a release version?
One other blog that may be helpful is the following one which shows how to increase the log level of server components such as the Offline OData component.
https://blogs.sap.com/2018/12/08/step-by-step-with-the-sap-cloud-platform-sdk-for-android-part-12-server-logging-and-tracing/
Regards,
Dan van Leeuwen
Hi Daniel Van Leeuwen - nice article. I was able to go through Blog 1 and 2 and get the Logon and registration to SCPms working fine. Now I am trying to do the offline piece as described in this Blog and get the SERVER_SYNCHONIZATION_ERROR - An unexpected error occurred while converting the OData metadata to the Offline OData client database metadata - errorDomain: OfflineStoreErrorDomain. I am not sure what is causing the error - as the "Read" seems to be working just fine.
Here is the error.
Most of these values were blank - so I went and filled in application/atom+xml and UTC for the date time offset.
Thanks for your help in advance. We are running this on the Windows build which we were able to get working after SDK 3.1 SP 2.
Regards,
Jay
I was able to reproduce the error as well. I think the metadata document from the OData service being used is unable to be parsed by the Offline OData component. A colleague I believe identified the problem to be a space in the name <Property Name="Airline Name" Type="Edm.String"/> returned from https://sapes5.sapdevcenter.com/sap/opu/odata/IWFND/RMTSAMPLEFLIGHT/$metadata.
I will attempt to raise the issue to the team that manages this service. In the mean time, perhaps have a look at the sample linked to below which uses a different OData service. https://blogs.sap.com/2017/01/24/getting-started-with-kapsel-part-10-offline-odatasp13/#crud
Regards,
Dan van Leeuwen
Thanks Dan - I will try the other service and post an update. I am able to debug the code through Visual Studio 2017 so it's very helpful - it took a while to get the environment all set up on Windows. I am surprised the the ES5 service somehow is broken - I thought that those SFLIGHT services would not have not changed in a while since the ES4 to ES5 move. Somehow also the service has V4 in the metadata xml - I thought this was supposed to be V2:
https://sapes5.sapdevcenter.com/sap/opu/odata/IWFND/RMTSAMPLEFLIGHT/
<?xml version="1.0" encoding="utf-8"?>
<app:service xml:base="https://sapes5.sapdevcenter.com/sap/opu/odata/IWFND/RMTSAMPLEFLIGHT/" xml:lang="en" xmlns:app="http://www.w3.org/2007/app" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:gp="http://www.sap.com/Protocols/SAPData/GenericPlayer" xmlns:m="http://schemas.microsoft.com/ado/2007/08/dataservices/metadata" xmlns:sap="http://www.sap.com/Protocols/SAPData" xmlns:ux="http://www.sap.com/Protocols/OData4SAP/UX">
Regards,
Jay
Hi Dan,
Thanks for your help. I was able to get the Offline working on Windows following the example https://blogs.sap.com/2017/01/24/getting-started-with-kapsel-part-10-offline-odatasp13/#crud
So now I do not have a metadata error anymore. The offline store is working.I am able to read in the data and have it displayed. I am able to update an existing record. But when I try to Create a record, it fails and states: "Value cannot be null" even though I have supplied all of the values in the form. I am looking into that issue. I am able to create a Product through Postman... but it's not working through the app. Any ideas? According to the metadata xml, ProductId and Name are the only ones that mandatory and the rest are nullable for Products.
It works fine posting the following via Postman:
Regards,
Jay
Wanted to mention that the error in https://sapes5.sapdevcenter.com/sap/opu/odata/IWFND/RMTSAMPLEFLIGHT/$metadata
has been fixed.
Thanks again for reporting that.
Dan van Leeuwen
Thanks Dan - I will work on with the new metadata. By any chance, were you able to check why the product creation is not working with the other sample? It the update is working fine but the create is not working and complaining about a value being null. I am able to create the entry with postman just supplying ProductId and Name so I am not sure why the OData create call is failing.
Regards,
Jay
Yes, I have also reproduced this. I think the underlying OData service has changed since this tutorial was written. I will post back if I find a solution. For the create case, it looks like supplierId is no longer nullable. Although I am puzzled why it worked for you in Postman. I think there are other issues as well. One thing that might help is to have a look at this blog post on using ILOData. The benefit is that you can work directly against the offline database and it may be easier than using postman. https://blogs.sap.com/2018/12/09/step-by-step-with-the-sap-cloud-platform-sdk-for-android-part-6c-using-ilodata/
Regards,
Dan van Leeuwen
That looks cool - so we can execute OData from the command line. Question - is there a way to open up the offline database in any SQL tool to view the tables and data and run queries? Right now the offline database appears more like a blackbox - it would be good to see all of the contents and run queries like any other database.
Regards,
Jay
The underlying OData service for the sample service changed to now include a media stream.
EntityType Name="Product" m:HasStream="true">
So, I believe it would now be two step process to create a new entity. The post would be to create the media stream and then you would need to patch or merge to update the other values such as Name, Price etc.
Another problem is that the datajs library is sending a header
DataServiceVersion: 1.0
which doesn't appear to be supported by the OData service.
innererror\":{\"details\":\"com.sap.xscript.data.DataServiceException: Unsupported DataServiceVersion: 1.0\"},\"message\":\"DataServiceException: Unsupported DataServiceVersion: 1.0
At this point I would suggest trying to work directly against your own OData service.
I am not aware of a publically available tool to query the offline database using SQL.
Regards,
Dan van Leeuwen
Hi Dan,
I just tried it out - but am still receiving an error. Any ideas?
I do see the metadata xml with the AirlineName as one word:
Regards,
Jay
I had also had to reset my password. I think that is happening for you as well. 401 indicates unauthorized. You can reset your password using this link.
https://register.sapdevcenter.com/SUPSignForms/
After it is reset, you need to set the new value by going to the below link.
SAP Gateway WebGUI
Regards,
Dan van Leeuwen
Hi Dan - it it necessary to reset the password? I am able to log in to the WebGUI with my current username and password so I am not sure how resetting the password would help.
Regards,
Jay
Perhaps uninstall and reinstall the mobile offline app. I believe 401 error is an unauthorized error. Did your password change? Are you able to open https://sapes5.sapdevcenter.com/sap/opu/odata/IWFND/RMTSAMPLEFLIGHT/$metadata in a new browser window and see the metadata after providing your user name and password?
Hi Dan,
It seems the app is not working correctly. I think the Offline component or the SCP mobile services offline service is not working as it should.
Here are my steps:
Step 1 – I start the app when online.
Step 2 – I clicked on Read and 138 entries are retrieved and shown:
Step 3 – Click on Read Offline Store – so the Store is OPEN and the records are being shown – no read has been done.
Step 4 – Turned off WIFI and then clicked on Read Local – I can see the readSuccessCallback function has been called indicating that the store has been read successfully
Step 5 – However no Products have been read and no records are displayed:
I see the defining requests for the Products and Stock being passed in and I see that the Open store call is successful. However, no records are being returned.
Here is the SCPms event log - which
I do see these messages which seem to indicate that no data is being returned and populated into the offline database:
"Database prepopulation has been disabled by the server administrator."
"Done adding data to the download from query ProductsDR. Modified 0 entities and 0 links Deleted 0 entities and 0 links."
"Done adding data to the download from query StockDR. Modified 0 entities and 123 links Deleted 0 entities and 0 links."
Not sure what to do. I am able to debug but somehow the store is not being populated correctly. Any ideas? I also created an OSS ticket about the issue.
Thanks,
Jay
Hi Dan,
When I changed the Read Local from:
https://hcpms-s0007610100trial.hanatrial.ondemand.com/com.kapsel.gs2/Products?$format=json&$orderby=Category,ProductId&$filter=sap.islocal()
to
https://hcpms-s0007610100trial.hanatrial.ondemand.com/com.kapsel.gs2/Products
then the data is being read from the Offline store it seems..
Could the $filter=sap.islocal() be causing an issue?
Also - I am trying to do the Create but I keep on getting the Value cannot be null issue. I am not sure why because I am passing all of the fields and the same works with PostMan.... so I wonder if its an issue with the Offline Data piece - on online mode it works fine.
In online mode, it works:
And here is the entry that was created and also read from the database:
Getting closer...
Regards,
Jay
The $filter=sap.islocal() is a filter that is specific to OData offline. When reading data against an offline store, adding this filter should return only the data that has been locally modified since the last time the offline store was synced. There is more information about this filter at Local Information About Entities and Relationships.
Regards,
Dan van Leeuwen
Hi Dan,
The offline database population did work after removing $filter=sap.islocal() to show all of the records. I am able to query products and update. I cannot get the create to work. I have tried supplying every value to the entity but it still complains about null values. It seems that the offline odata has issues and maybe has changed since you had written the blog. We already found issues with the trial version of the SDK and had to download the latest from SAP software downloads. I am wondering if it is a similar issue. I have created an SAP support ticket hoping that the developers can take a look. I have asked them to try to get your blog example working on Windows. Do you by any chance have access to a Windows environment and see if the Blog works with the latests SDK?
Thanks,
Jay
Agreed. The OData service this blog was based on has been updated and sample needs to be changed or possibly a different OData service will need to be used.
I don't at the moment have a windows development environment for Kapsel set up. I would suggest though using either your own OData backend or possibly following an older version of this blog post that uses a different OData backend. https://blogs.sap.com/2015/07/19/getting-started-with-kapsel-part-10-offline-odata-sp09/
The above version uses the read write odata service described at https://services.odata.org/
Regards,
Dan van Leeuwen
I updated the sample. The current sample uses an older Mobile Services sample OData service. /SampleServices/ESPM.svc/
The newer one is of the format /mobileservices/origin/hcpms/ESPM.svc/v2.
My above comments were related to using the newer OData service.
On Android I was able to get the create working. The change is to the createRecord method. It now calls patchEntity which performs a PATCH following the initial POST.
Give it a try. Hope that it fixes the issue.
Regards,
Dan van Leeuwen
Hi Dan,
We now have our offline app working on Windows and Android - so we are making progress. Can we do a deep entity insert/update with the offline odata plugin? We are running into issues around that. We have header entities with child records and would like to do the deep entity inserts and updates.
Regards,
Jay
Have a look at the limitations section. Note there is a restriction around offline if you are performing a deep insert and the navigation property between the entities is one to many.
https://help.sap.com/doc/d9c75eebcfa840c8a4aa4b0e6a8136de/3.0.14/en-US/889d29b3fac0456b812d86b5794c6e54.html
Is that the problem you are running into to?
Regards,
Dan van Leeuwen
Dear Daniel
thanks for the great post! But i would please need some help from you.
When trying to connect to an OnPremise ABAP System we get an error:
We also tried to change the projects config.xml
<access origin=”*”/> to the SCPms URL.
Thanks for your help!
BR,
Martin
There are more details on the Cordova Whitelist plugin at https://cordova.apache.org/docs/en/latest/reference/cordova-plugin-whitelist/
Can you perhaps post the code that is making use of that URL?
Is the webview being navigated to that URL instead of making a data request to that URL?
One other general suggestion would be to make sure you have the very latest version of the SDK. I think there were some issues with a recent patch.
Regards,
Dan van Leeuwen
Hi Daniel,
Using following parameters, we added automatic flush & refresh in our application
Do you know if we can add a eventListener on the autoRefresh, if the refresh is success, we can update / refresh our screen?
Thx!
Regards,
Hans
The flow should be that when your app is brought into the foreground, the onResume event is triggered which tells the store to perform a refresh. When it completes, it should call the onSuccess or onError method. Is that not happening?
https://help.sap.com/viewer/42dc90f1e1ed45d9aafad60c80646d10/3.1.4/en-US/ed65d7f0acbd4ae2bb4bb59e0aa389ad.html
Regards,
Dan van Leeuwen
Hi Daniel,
The onResume function is triggerd en the refresh is also triggerd after comming out of background. But the "openStoreSuccessCallback" isn't called afterwards.
Regards,
Hans
I tried it out this morning and am seeing the same behavior you describe. The refresh is occurring but is not calling the success or error callback.
One approach to handle this might be to instead call refresh from the onResume event and not use the autoRefresh store option.
Regards,
Dan van Leeuwen
Alright, that's also an option, thx!
Small other question. If a user is registered on SCPms (and has an Registration ID). if we use the automatic removal and the user registration ID is removed.
What is the easiest way to check this (during start-up, or ...)?
I see currently that, when the registration ID was removed and we do a sync, we'll get a http 404 error message. If I do a sap.Logon.core.reset after this, we can login again (an I see the new registration ID in SCPms), but somewhere in the process, the new registration ID is not used in the plugins because during the next sync, we still get a http 404.
Regards,
Hans
I tried quickly to reproduce the issue but was not able to on Android.
I deleted the registration manually, received the 404, called reset, re-registered and then was able to perform a flush/refresh.
You might want to verify that you have the latest version of the SDK. I used 4.2.6. If the issue is still happening, you may wish to raise the issue with support and provide a sample app that reproduces the issue.
Perhaps as a workaround you can bump up the automatic removal time value.
Regards,
Dan van Leeuwen
Hi Daniel,
We've made progress and we have our offline app working on iOS, Android and Windows. However, right now we are running into an issue when we create a new entity in offline mode - we see the ID as blank and when we flush and refresh, we see the entity being created in SAP. However, the ID is not getting updated based on the SAP ID value. We are using your code as a reference. When we have a new entry created in offline mode, and then when we are back online and flush and refresh, shouldn't our offline store id get updated? Not sure what we are missing here for that to happen. If we delete the database and open a new one, we would have the value but we do not want to delete the Ultralite database if we do not need to.
Your feedback would be appreciated.
Thanks,
Jay
We created a new entity AVANTELJ for a business partner. When we flush and refresh, we see this in ES5:
But if we look into UltraLite, we can see this entity ID still has the GUID and not the ID from SAP:
Shouldn't that entry get updated after the OData call for the create is made to SAP on the refresh after the Flush?
Regards,
Jay
Do you have an onrequesterror listener set? Is it being called? There is an example of this in this blog post.
Hope that helps,
Dan van Leeuwen
Hi Dan,
I do have that code - I do not think it's being called. The entity is being created in SAP ES5 - so there is no error on invoking the backend OData create. However, the keys are not getting updated in the database as expected.
Will investigate more - but the flush with refresh should refresh all of the OData. Is the refresh based on the defining requests - does it make that call again and replace/update entries or does it try to sync up based on keys?
Thanks,
Jay
The refresh is based on the defining queries. One other thing to try would be to increase the log level of the client and the log level of the offline component in the server to see if the logs contain any clues. The code to set the client log level is shown above.
There is an example in the following blog post around changing the log level of the offline component in the server.
https://blogs.sap.com/2018/12/08/step-by-step-with-the-sap-cloud-platform-sdk-for-android-part-12-server-logging-and-tracing/
Regards,
Dan van Leeuwen
Hi Daniel,
We are develop an offline application. We have four offline stores and need to refresh them whenever the app is started and has connection.
We try to execute the refresh of all stores using Promise.all() method of Javascript. Alternative we try loop the stores and execute refresh method on each store. Both methods did'n work.
The only way to do this is executing the refresh method of each store sequencially? This represents a slow performance for the application since we must wait for each store to finish refreshing to start the update of the next store.
Thank.