@lowdefy/shell 3.23.3 → 7.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +183 -183
- package/README.md +364 -2
- package/app/(t0,t1,t2,t3,t4)/[...path].js +38 -0
- package/app/(t0,t1,t2,t3,t4)/_layout.js +48 -0
- package/app/(t0,t1,t2,t3,t4)/index.js +38 -0
- package/app/+native-intent.js +39 -0
- package/app/_layout.js +36 -0
- package/app.config.ts +183 -0
- package/appLinks.js +65 -0
- package/applyShellPlugins.js +43 -0
- package/authCallbackIntentFilters.js +46 -0
- package/babel.config.js +25 -0
- package/checkShellConfig.js +172 -0
- package/checkShellPlugins.js +74 -0
- package/getPrivacyManifest.js +39 -0
- package/metro.config.js +73 -0
- package/native/LowdefyAppShortcuts.swift.template +84 -0
- package/native/withAppShortcuts.js +60 -0
- package/native-fingerprint.json +16 -0
- package/package.json +120 -40
- package/profiles.js +607 -0
- package/readShellConfig.js +36 -0
- package/resolveShellPlugins.js +109 -0
- package/runtimeVersion.js +112 -0
- package/scripts/checkReleaseBundle.mjs +358 -0
- package/scripts/createDayjsLocales.mjs +67 -0
- package/scripts/createShellAssets.mjs +54 -0
- package/scripts/createShellVersion.mjs +83 -0
- package/scripts/createTypeManifest.mjs +205 -0
- package/shell.dev.json +11 -0
- package/shellVariant.js +126 -0
- package/src/ShellContext.js +32 -0
- package/src/actions/CopyToClipboard.js +29 -0
- package/src/actions/CopyToClipboard.test.js +33 -0
- package/src/actions/GeolocationCurrentPosition.js +61 -0
- package/src/actions/GeolocationCurrentPosition.test.js +79 -0
- package/src/actions/GetLocalStorage.js +36 -0
- package/src/actions/RemoveLocalStorage.js +31 -0
- package/src/actions/ScrollTo.js +66 -0
- package/src/actions/ScrollTo.test.js +83 -0
- package/src/actions/SetFocus.js +33 -0
- package/src/actions/SetFocus.test.js +49 -0
- package/src/actions/SetLocalStorage.js +34 -0
- package/src/actions/localStorage.test.js +68 -0
- package/src/actions/measureInScrollView.js +36 -0
- package/src/actions/nativeActions.js +41 -0
- package/src/auth/AuthButton.js +44 -0
- package/src/auth/AuthGate.js +57 -0
- package/src/auth/AuthLink.js +43 -0
- package/src/auth/LockScreen.js +67 -0
- package/src/auth/LockScreen.test.js +37 -0
- package/src/auth/SignInScreen.js +268 -0
- package/src/auth/SignInScreen.test.js +187 -0
- package/src/auth/createAppAuth.js +325 -0
- package/src/auth/createAppAuth.test.js +424 -0
- package/src/auth/createAuthApi.js +115 -0
- package/src/auth/createAuthApi.test.js +138 -0
- package/src/auth/createCoreAuth.js +96 -0
- package/src/auth/createCoreAuth.test.js +188 -0
- package/src/auth/createDeviceKey.js +62 -0
- package/src/auth/createDeviceKey.test.js +54 -0
- package/src/auth/createSessionStores.js +45 -0
- package/src/auth/createTokenStore.js +103 -0
- package/src/auth/createTokenStore.test.js +116 -0
- package/src/auth/getAuthSettings.js +24 -0
- package/src/auth/getAuthView.js +39 -0
- package/src/auth/getAuthView.test.js +52 -0
- package/src/auth/getSheetReturnUrl.js +35 -0
- package/src/auth/getTokenStoreOptions.js +46 -0
- package/src/auth/isBackgroundShell.js +26 -0
- package/src/auth/isSheetReturnLink.js +35 -0
- package/src/auth/parseQuery.js +39 -0
- package/src/auth/purgeAppData.js +80 -0
- package/src/auth/purgeAppData.test.js +104 -0
- package/src/auth/shouldLock.js +26 -0
- package/src/auth/signInWithSheet.js +97 -0
- package/src/auth/signInWithSheet.test.js +205 -0
- package/src/auth/styles.js +78 -0
- package/src/auth/unregisterPushToken.js +62 -0
- package/src/auth/unregisterPushToken.test.js +67 -0
- package/src/auth/usesBiometricUnlock.js +23 -0
- package/src/auth/watchSession.js +47 -0
- package/src/auth/withoutCookies.js +26 -0
- package/src/cache/createSqliteCacheStore.js +113 -0
- package/src/cache/createSqliteCacheStore.test.js +93 -0
- package/src/cache/getCacheKey.js +25 -0
- package/src/cache/getLastCacheKeyName.js +22 -0
- package/src/components/AppProvider.js +118 -0
- package/src/components/Frame.js +116 -0
- package/src/components/Frame.test.js +101 -0
- package/src/components/LoadingScreen.js +35 -0
- package/src/components/MenuButton.js +121 -0
- package/src/components/MenuButton.test.js +74 -0
- package/src/components/MessageScreen.js +51 -0
- package/src/components/NavigationTheme.js +33 -0
- package/src/components/NoConnectionBanner.js +51 -0
- package/src/components/NoConnectionBanner.test.js +73 -0
- package/src/components/OtherPageScreen.js +95 -0
- package/src/components/OtherPageScreen.test.js +74 -0
- package/src/components/PageScreen.js +254 -0
- package/src/components/PageScreen.test.js +492 -0
- package/src/components/RouteErrorScreen.js +47 -0
- package/src/components/ShellRoot.js +34 -0
- package/src/components/ShellRoot.test.js +62 -0
- package/src/components/ShellThemeProvider.js +45 -0
- package/src/components/SwitchToTab.js +36 -0
- package/src/components/SwitchToTab.test.js +63 -0
- package/src/components/TabIconImages.js +72 -0
- package/src/components/UpdateScreen.js +40 -0
- package/src/components/getNavigationTheme.js +44 -0
- package/src/components/getNavigationTheme.test.js +41 -0
- package/src/components/styles.js +50 -0
- package/src/components/useSurfaceTokens.js +47 -0
- package/src/config/getAppApiBase.js +34 -0
- package/src/config/getShellConfig.js +37 -0
- package/src/config/getShellConfig.test.js +80 -0
- package/src/createShellApp.js +126 -0
- package/src/dev/DevShell.js +239 -0
- package/src/dev/DevShell.test.js +233 -0
- package/src/dev/connect/ConfirmSheet.js +71 -0
- package/src/dev/connect/ConnectScreen.js +117 -0
- package/src/dev/connect/QrScanner.js +89 -0
- package/src/dev/connection/createDevShellConfig.js +47 -0
- package/src/dev/connection/createDevTransport.js +51 -0
- package/src/dev/connection/createDevTransport.test.js +87 -0
- package/src/dev/connection/createServerList.js +48 -0
- package/src/dev/connection/isLoopbackHost.js +25 -0
- package/src/dev/connection/pairedShellApp.test.js +104 -0
- package/src/dev/connection/parseDevLink.js +66 -0
- package/src/dev/connection/parseDevLink.test.js +97 -0
- package/src/dev/connection/parseQuery.js +33 -0
- package/src/dev/connection/parseServerInput.js +40 -0
- package/src/dev/connection/parseServerUrl.js +45 -0
- package/src/dev/devStyles.js +97 -0
- package/src/dev/index.js +21 -0
- package/src/dev/inject/createDeviceInjections.js +188 -0
- package/src/dev/inject/createDeviceInjections.test.js +224 -0
- package/src/dev/inject/createOfflineFetch.js +35 -0
- package/src/dev/inject/createOfflineFetch.test.js +68 -0
- package/src/dev/inject/injectPush.js +60 -0
- package/src/dev/inject/injectShare.js +55 -0
- package/src/dev/inject/withInjectedDevice.js +98 -0
- package/src/dev/menu/BlockGallery.js +33 -0
- package/src/dev/menu/DevMenu.js +121 -0
- package/src/dev/menu/createLogBuffer.js +83 -0
- package/src/dev/menu/watchShake.js +52 -0
- package/src/dev/menu/watchShake.test.js +61 -0
- package/src/dev/recorder/createNativeRecorder.js +204 -0
- package/src/dev/recorder/createNativeRecorder.test.js +307 -0
- package/src/dev/redirectDevLink.js +30 -0
- package/src/dev/redirectDevLink.test.js +49 -0
- package/src/dev/steps/createStepRunner.js +184 -0
- package/src/dev/steps/createStepRunner.test.js +262 -0
- package/src/dev/steps/findBlock.js +78 -0
- package/src/dev/steps/isContextSettled.js +37 -0
- package/src/dev/steps/waitForSettle.js +39 -0
- package/src/dev/steps/waitUntil.js +40 -0
- package/src/dev/tab/DevTabClient.js +97 -0
- package/src/dev/tab/answerEval.js +39 -0
- package/src/dev/tab/answerInspect.js +45 -0
- package/src/dev/tab/createDevTabClient.js +272 -0
- package/src/dev/tab/createDevTabClient.test.js +317 -0
- package/src/dev/tab/describeTabError.js +35 -0
- package/src/dev/tab/devClientMarker.js +22 -0
- package/src/dev/tab/devClientMarker.test.js +23 -0
- package/src/dev/tab/findPageContext.js +43 -0
- package/src/dev/tab/getShownPage.js +42 -0
- package/src/dev/tab/readEventStream.js +68 -0
- package/src/dev/tab/reloadApp.js +42 -0
- package/src/dev/tab/reloadApp.test.js +115 -0
- package/src/device/DeviceHosts.js +32 -0
- package/src/device/basic/createFilesFeature.js +41 -0
- package/src/device/basic/createHapticsFeature.js +46 -0
- package/src/device/basic/createKeepAwakeFeature.js +37 -0
- package/src/device/basic/createPhotosFeature.js +40 -0
- package/src/device/basic/createRequestQueueFeature.js +37 -0
- package/src/device/basic/createShareFeature.js +63 -0
- package/src/device/basic/createShareFeature.test.js +47 -0
- package/src/device/basic/createSpeechFeature.js +43 -0
- package/src/device/basic/createUploadFeature.js +41 -0
- package/src/device/basic/toImagePickerOptions.js +41 -0
- package/src/device/basic/toPickedFile.js +54 -0
- package/src/device/calendar/index.js +68 -0
- package/src/device/contacts/index.js +67 -0
- package/src/device/createDevice.js +65 -0
- package/src/device/device.js +27 -0
- package/src/device/field/MapView.js +93 -0
- package/src/device/field/ScanCodeSheet.js +86 -0
- package/src/device/field/ScannerView.js +52 -0
- package/src/device/field/createAudioFeature.js +70 -0
- package/src/device/field/createBrightnessFeature.js +40 -0
- package/src/device/field/createCameraFeature.js +56 -0
- package/src/device/field/createDocumentScannerFeature.js +51 -0
- package/src/device/field/createLocationFeature.js +39 -0
- package/src/device/field/createScanRequests.js +55 -0
- package/src/device/field/index.js +39 -0
- package/src/device/field/scanRequests.js +22 -0
- package/src/device/injected.js +60 -0
- package/src/device/injected.test.js +39 -0
- package/src/device/nfc/index.js +89 -0
- package/src/device/nfc/toNfcRecord.js +44 -0
- package/src/device/nfc/toNfcRecord.test.js +48 -0
- package/src/device/tracking/createBacklog.js +63 -0
- package/src/device/tracking/createBacklog.test.js +47 -0
- package/src/device/tracking/index.js +211 -0
- package/src/device/tracking/index.test.js +110 -0
- package/src/host/createDeviceStore.js +32 -0
- package/src/host/createMemoryStore.js +32 -0
- package/src/host/createNetworkStatus.js +94 -0
- package/src/host/createNetworkStatus.test.js +94 -0
- package/src/host/createRouterAdapter.js +33 -0
- package/src/host/createShellFetch.js +62 -0
- package/src/host/createShellFetch.test.js +165 -0
- package/src/host/createShellGlobals.js +62 -0
- package/src/host/createShellHost.js +83 -0
- package/src/host/createShellHost.test.js +36 -0
- package/src/host/getOrigin.js +31 -0
- package/src/host/isNetworkError.js +24 -0
- package/src/host/isOnline.js +22 -0
- package/src/host/networkStatus.js +24 -0
- package/src/host/readServerError.js +45 -0
- package/src/host/readServerError.test.js +85 -0
- package/src/host/reportShellError.js +47 -0
- package/src/host/reportShellError.test.js +79 -0
- package/src/host/withNetworkErrors.js +38 -0
- package/src/host/withNetworkErrors.test.js +61 -0
- package/src/host/withShellHeader.js +25 -0
- package/src/host/withShellHeader.test.js +28 -0
- package/src/js/evaluateJsEntries.js +43 -0
- package/src/js/evaluateJsEntries.test.js +48 -0
- package/src/locale/createShellLocale.js +112 -0
- package/src/locale/createShellLocale.test.js +74 -0
- package/src/locale/dayjsLocales.js +165 -0
- package/src/locale/getDeviceLocales.js +23 -0
- package/src/locale/loadDayjsLocale.js +32 -0
- package/src/locale/resolveActiveLocale.js +46 -0
- package/src/locale/resolveActiveLocale.test.js +40 -0
- package/src/native/backgroundFetch.js +97 -0
- package/src/native/callEndpoint.js +63 -0
- package/src/native/createAppEventTrigger.js +70 -0
- package/src/native/currentApp.js +49 -0
- package/src/native/getPagePath.js +32 -0
- package/src/native/openPath.js +55 -0
- package/src/native/openPath.test.js +39 -0
- package/src/native/parseShellLink.js +38 -0
- package/src/native/parseShellLink.test.js +29 -0
- package/src/native/startAppServices.js +94 -0
- package/src/native/startAppServices.test.js +97 -0
- package/src/native/startShareIn.js +59 -0
- package/src/native/startShortcuts.js +88 -0
- package/src/native/startShortcuts.test.js +59 -0
- package/src/native/toSharePayload.js +47 -0
- package/src/native/toSharePayload.test.js +47 -0
- package/src/native/whenAppReady.js +48 -0
- package/src/navigation/SlotContext.js +24 -0
- package/src/navigation/frameSlots.js +27 -0
- package/src/navigation/getFrame.js +30 -0
- package/src/navigation/getInitialScreenOptions.js +63 -0
- package/src/navigation/getInitialScreenOptions.test.js +66 -0
- package/src/navigation/getPresentation.js +28 -0
- package/src/navigation/getSlotRootPath.js +27 -0
- package/src/navigation/getStackOptions.js +185 -0
- package/src/navigation/getTabHref.js +45 -0
- package/src/navigation/getTabIcons.js +43 -0
- package/src/navigation/isPageUnderHeader.js +46 -0
- package/src/navigation/navigation.test.js +342 -0
- package/src/navigation/toAppLinkPath.js +43 -0
- package/src/navigation/toAppLinkPath.test.js +53 -0
- package/src/navigation/toRouterHref.js +29 -0
- package/src/notifications/createNotificationsFeature.js +61 -0
- package/src/notifications/getCategoryAction.js +31 -0
- package/src/notifications/handleNotificationResponse.js +82 -0
- package/src/notifications/registerPushToken.js +71 -0
- package/src/notifications/startNotifications.js +114 -0
- package/src/notifications/toCategoryActions.js +42 -0
- package/src/notifications/toNotificationEvent.js +36 -0
- package/src/notifications/toScheduleRequest.js +62 -0
- package/src/plugins/appTypes.js +28 -0
- package/src/plugins/prefixTypes.js +28 -0
- package/src/plugins/shellPlugins.json +15 -0
- package/src/plugins/typeManifest.js +789 -0
- package/src/plugins/types.js +64 -0
- package/src/plugins/types.test.js +173 -0
- package/src/polyfills/installGetRandomValues.js +28 -0
- package/src/queue/createRequestQueue.js +198 -0
- package/src/queue/createRequestQueue.test.js +215 -0
- package/src/queue/createSqliteQueueStore.js +121 -0
- package/src/queue/summarizeQueue.js +39 -0
- package/src/session/createAppSession.js +323 -0
- package/src/session/createAppSession.test.js +391 -0
- package/src/session/getPageView.js +59 -0
- package/src/session/getPageView.test.js +137 -0
- package/src/sync/encodePagePath.js +27 -0
- package/src/sync/fetchPage.js +72 -0
- package/src/sync/fetchPage.test.js +84 -0
- package/src/sync/fetchRoot.js +43 -0
- package/src/sync/fetchUser.js +33 -0
- package/src/sync/runLimited.js +36 -0
- package/src/sync/toRootConfig.js +35 -0
- package/src/test/createFakeNetwork.js +40 -0
- package/src/test/createFakeSecureStore.js +40 -0
- package/src/test/createFakeServer.js +98 -0
- package/src/test/createMemoryCacheStore.js +59 -0
- package/src/test/createNetworkError.js +24 -0
- package/src/test/expoImage.js +42 -0
- package/src/test/expoNotifications.js +40 -0
- package/src/test/expoQuickActions.js +22 -0
- package/src/test/expoSpeechRecognition.js +31 -0
- package/src/test/expoTaskManager.js +25 -0
- package/src/test/expoUi.js +55 -0
- package/src/test/expoUiBottomSheet.js +64 -0
- package/src/test/expoUiDateTimePicker.js +26 -0
- package/src/test/expoUiMenu.js +46 -0
- package/src/test/expoUiSegmentedControl.js +47 -0
- package/src/test/expoUiSwiftUi.js +62 -0
- package/src/test/expoUiSwiftUiModifiers.js +23 -0
- package/src/test/expoWebBrowser.js +23 -0
- package/src/test/fixtures.js +98 -0
- package/src/test/nativeModule.js +33 -0
- package/src/test/renderPageScreen.js +109 -0
- package/src/types/checkTypeSet.js +39 -0
- package/src/types/collectPageTypes.js +49 -0
- package/src/types/collectPageTypes.test.js +90 -0
- package/src/types/findMissingPageTypes.js +40 -0
- package/src/version/compareVersions.js +38 -0
- package/src/version/isShellTooOld.js +30 -0
- package/src/version/shellVersion.js +21 -0
- package/src/version/shellVersion.test.js +32 -0
- package/src/widgets/fetchWidgetData.js +59 -0
- package/src/widgets/index.js +156 -0
- package/src/widgets/refreshWidgets.js +31 -0
- package/withExcludedNativeModules.js +93 -0
- package/dist/location.js +0 -33
- package/dist/public/apple-touch-icon.png +0 -0
- package/dist/public/icon-32.png +0 -0
- package/dist/public/icon-512.png +0 -0
- package/dist/public/icon.svg +0 -17
- package/dist/public/logo-dark-theme.png +0 -0
- package/dist/public/logo-light-theme.png +0 -0
- package/dist/public/logo-square-dark-theme.png +0 -0
- package/dist/public/logo-square-light-theme.png +0 -0
- package/dist/public/manifest.webmanifest +0 -16
- package/dist/shell/246.93432ff68186aae6c87b.js +0 -2
- package/dist/shell/246.93432ff68186aae6c87b.js.LICENSE.txt +0 -8
- package/dist/shell/454.b597ca21366afda000af.js +0 -2
- package/dist/shell/454.b597ca21366afda000af.js.LICENSE.txt +0 -14
- package/dist/shell/484.d645e6c09079f45ce044.js +0 -1
- package/dist/shell/706.a66931e02a549fd269bd.js +0 -2
- package/dist/shell/706.a66931e02a549fd269bd.js.LICENSE.txt +0 -8
- package/dist/shell/9.78be8c1b6c3986f404fc.js +0 -2
- package/dist/shell/9.78be8c1b6c3986f404fc.js.LICENSE.txt +0 -45
- package/dist/shell/922.43b7a3ebe22ef05b3049.js +0 -2
- package/dist/shell/922.43b7a3ebe22ef05b3049.js.LICENSE.txt +0 -14
- package/dist/shell/index.html +0 -57
- package/dist/shell/main.f01501a75909f38ccc32.js +0 -1
- package/dist/shell/runtime.e50f6c3a5f7c74a0a717.js +0 -1
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Copyright 2020-2026 Lowdefy, Inc
|
|
3
|
+
|
|
4
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
you may not use this file except in compliance with the License.
|
|
6
|
+
You may obtain a copy of the License at
|
|
7
|
+
|
|
8
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
|
|
10
|
+
Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
See the License for the specific language governing permissions and
|
|
14
|
+
limitations under the License.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import * as Location from 'expo-location';
|
|
18
|
+
import * as TaskManager from 'expo-task-manager';
|
|
19
|
+
import { UserError } from '@lowdefy/errors';
|
|
20
|
+
|
|
21
|
+
import callEndpoint from '../../native/callEndpoint.js';
|
|
22
|
+
import createBacklog from './createBacklog.js';
|
|
23
|
+
import createDeviceStore from '../../host/createDeviceStore.js';
|
|
24
|
+
import { getCurrentApp, peekCurrentApp } from '../../native/currentApp.js';
|
|
25
|
+
import reportShellError from '../../host/reportShellError.js';
|
|
26
|
+
|
|
27
|
+
const LOCATION_TASK = 'lowdefy-location-tracking';
|
|
28
|
+
const GEOFENCE_TASK = 'lowdefy-geofencing';
|
|
29
|
+
const CONFIG_KEY = 'lowdefy-shell:tracking';
|
|
30
|
+
const ACCURACY = {
|
|
31
|
+
low: Location.Accuracy.Low,
|
|
32
|
+
balanced: Location.Accuracy.Balanced,
|
|
33
|
+
high: Location.Accuracy.High,
|
|
34
|
+
navigation: Location.Accuracy.BestForNavigation,
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
// The device store and the backlog of posts, opened on first use: loading the module, which every
|
|
38
|
+
// field-tracking shell does at launch, touches no storage.
|
|
39
|
+
let stores = null;
|
|
40
|
+
function getStores() {
|
|
41
|
+
if (stores === null) {
|
|
42
|
+
const kv = createDeviceStore();
|
|
43
|
+
stores = { kv, backlog: createBacklog({ key: 'lowdefy-shell:tracking-backlog', kv }) };
|
|
44
|
+
}
|
|
45
|
+
return stores;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function readConfig() {
|
|
49
|
+
return JSON.parse(getStores().kv.get(CONFIG_KEY) ?? '{}');
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function writeConfig(update) {
|
|
53
|
+
getStores().kv.set(CONFIG_KEY, JSON.stringify({ ...readConfig(), ...update }));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// A refusal the server will repeat however often the post is sent (bad payload, missing endpoint,
|
|
57
|
+
// forbidden): the post is dropped and reported, so one bad post does not hold up the backlog behind
|
|
58
|
+
// it. A lost session (401), a timeout or rate limit, and server errors stay for the next wake.
|
|
59
|
+
function isRefused(error) {
|
|
60
|
+
const { status } = error ?? {};
|
|
61
|
+
return status >= 400 && status < 500 && ![401, 408, 429].includes(status);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Posts to an endpoint with the open app's session; on failure the post waits in the backlog for
|
|
65
|
+
// the next wake. Kept posts go first, so the endpoint sees them in order.
|
|
66
|
+
async function post({ endpointId, payload }) {
|
|
67
|
+
const app = await getCurrentApp();
|
|
68
|
+
const { backlog } = getStores();
|
|
69
|
+
const send = async (entry) => {
|
|
70
|
+
try {
|
|
71
|
+
await callEndpoint({ app, endpointId: entry.endpointId, payload: entry.payload });
|
|
72
|
+
} catch (error) {
|
|
73
|
+
if (!isRefused(error)) throw error;
|
|
74
|
+
reportShellError({ error, session: app.session });
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
await backlog.flush(send);
|
|
78
|
+
try {
|
|
79
|
+
await send({ endpointId, payload });
|
|
80
|
+
} catch (error) {
|
|
81
|
+
reportShellError({ error, session: app.session });
|
|
82
|
+
backlog.add({ endpointId, payload });
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function toPoint({ coords, timestamp }) {
|
|
87
|
+
return {
|
|
88
|
+
latitude: coords.latitude,
|
|
89
|
+
longitude: coords.longitude,
|
|
90
|
+
accuracy: coords.accuracy,
|
|
91
|
+
altitude: coords.altitude,
|
|
92
|
+
heading: coords.heading,
|
|
93
|
+
speed: coords.speed,
|
|
94
|
+
timestamp: new Date(timestamp).toISOString(),
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Background tasks are defined when the bundle loads, so a launch in the background finds them.
|
|
99
|
+
TaskManager.defineTask(LOCATION_TASK, async ({ data, error }) => {
|
|
100
|
+
const { locationEndpointId } = readConfig();
|
|
101
|
+
if (error) {
|
|
102
|
+
reportShellError({ error });
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
if (typeof locationEndpointId !== 'string') return;
|
|
106
|
+
await post({
|
|
107
|
+
endpointId: locationEndpointId,
|
|
108
|
+
payload: { locations: data.locations.map(toPoint) },
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
TaskManager.defineTask(GEOFENCE_TASK, async ({ data, error }) => {
|
|
113
|
+
const { geofenceEndpointId } = readConfig();
|
|
114
|
+
if (error) {
|
|
115
|
+
reportShellError({ error });
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
const event = {
|
|
119
|
+
event: data.eventType === Location.GeofencingEventType.Enter ? 'enter' : 'exit',
|
|
120
|
+
region: {
|
|
121
|
+
id: data.region.identifier,
|
|
122
|
+
latitude: data.region.latitude,
|
|
123
|
+
longitude: data.region.longitude,
|
|
124
|
+
radius: data.region.radius,
|
|
125
|
+
},
|
|
126
|
+
timestamp: new Date().toISOString(),
|
|
127
|
+
};
|
|
128
|
+
peekCurrentApp()?.triggerAppEvent({ name: 'onGeofence', event });
|
|
129
|
+
if (typeof geofenceEndpointId === 'string') {
|
|
130
|
+
await post({ endpointId: geofenceEndpointId, payload: event });
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
// Background location needs "while using" first, then "always".
|
|
135
|
+
async function allowBackground() {
|
|
136
|
+
const foreground = await Location.requestForegroundPermissionsAsync();
|
|
137
|
+
if (!foreground.granted) {
|
|
138
|
+
throw new UserError('Location permission was denied.');
|
|
139
|
+
}
|
|
140
|
+
const background = await Location.requestBackgroundPermissionsAsync();
|
|
141
|
+
if (!background.granted) {
|
|
142
|
+
throw new UserError('Background location permission was denied.');
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* The tracking device feature (profile field-tracking): location tracking and geofences that keep
|
|
148
|
+
* running with the app closed. Points and region changes post to the app's endpoints (with the
|
|
149
|
+
* session token the profile keeps readable after first unlock); posts that fail wait for the next
|
|
150
|
+
* wake. iOS watches at most 20 regions.
|
|
151
|
+
*/
|
|
152
|
+
const tracking = {
|
|
153
|
+
async startTracking({
|
|
154
|
+
accuracy = 'balanced',
|
|
155
|
+
distanceInterval = 50,
|
|
156
|
+
endpointId,
|
|
157
|
+
notificationBody = 'Recording your trip.',
|
|
158
|
+
notificationTitle = 'Location on',
|
|
159
|
+
timeInterval = 60,
|
|
160
|
+
}) {
|
|
161
|
+
await allowBackground();
|
|
162
|
+
writeConfig({ locationEndpointId: endpointId });
|
|
163
|
+
await Location.startLocationUpdatesAsync(LOCATION_TASK, {
|
|
164
|
+
accuracy: ACCURACY[accuracy] ?? ACCURACY.balanced,
|
|
165
|
+
distanceInterval,
|
|
166
|
+
timeInterval: timeInterval * 1000,
|
|
167
|
+
deferredUpdatesInterval: timeInterval * 1000,
|
|
168
|
+
pausesUpdatesAutomatically: true,
|
|
169
|
+
showsBackgroundLocationIndicator: true,
|
|
170
|
+
foregroundService: { notificationTitle, notificationBody },
|
|
171
|
+
});
|
|
172
|
+
return { tracking: true };
|
|
173
|
+
},
|
|
174
|
+
async stopTracking() {
|
|
175
|
+
if (await Location.hasStartedLocationUpdatesAsync(LOCATION_TASK)) {
|
|
176
|
+
await Location.stopLocationUpdatesAsync(LOCATION_TASK);
|
|
177
|
+
}
|
|
178
|
+
return { tracking: false };
|
|
179
|
+
},
|
|
180
|
+
async startGeofencing({ endpointId, regions }) {
|
|
181
|
+
if (regions.length > 20) {
|
|
182
|
+
throw new Error(`StartGeofencing watches at most 20 regions. Received ${regions.length}.`);
|
|
183
|
+
}
|
|
184
|
+
await allowBackground();
|
|
185
|
+
writeConfig({ geofenceEndpointId: endpointId ?? null });
|
|
186
|
+
await Location.startGeofencingAsync(
|
|
187
|
+
GEOFENCE_TASK,
|
|
188
|
+
regions.map((region) => ({
|
|
189
|
+
identifier: region.id,
|
|
190
|
+
latitude: region.latitude,
|
|
191
|
+
longitude: region.longitude,
|
|
192
|
+
radius: region.radius,
|
|
193
|
+
notifyOnEnter: true,
|
|
194
|
+
notifyOnExit: true,
|
|
195
|
+
}))
|
|
196
|
+
);
|
|
197
|
+
return { regions: regions.length };
|
|
198
|
+
},
|
|
199
|
+
async stopGeofencing() {
|
|
200
|
+
if (await Location.hasStartedGeofencingAsync(GEOFENCE_TASK)) {
|
|
201
|
+
await Location.stopGeofencingAsync(GEOFENCE_TASK);
|
|
202
|
+
}
|
|
203
|
+
return { regions: 0 };
|
|
204
|
+
},
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
// The `field-tracking` profile's device features (profiles.js TRACKING_ENTRY).
|
|
208
|
+
const features = { tracking };
|
|
209
|
+
const hosts = [];
|
|
210
|
+
|
|
211
|
+
export { features, hosts };
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Copyright 2020-2026 Lowdefy, Inc
|
|
3
|
+
|
|
4
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
you may not use this file except in compliance with the License.
|
|
6
|
+
You may obtain a copy of the License at
|
|
7
|
+
|
|
8
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
|
|
10
|
+
Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
See the License for the specific language governing permissions and
|
|
14
|
+
limitations under the License.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const mockTasks = {};
|
|
18
|
+
const mockValues = new Map();
|
|
19
|
+
const mockApp = { triggerAppEvent: jest.fn() };
|
|
20
|
+
|
|
21
|
+
jest.mock('expo-task-manager', () => ({
|
|
22
|
+
defineTask: (name, task) => {
|
|
23
|
+
mockTasks[name] = task;
|
|
24
|
+
},
|
|
25
|
+
}));
|
|
26
|
+
jest.mock('expo-location', () => ({
|
|
27
|
+
Accuracy: {},
|
|
28
|
+
GeofencingEventType: { Enter: 1, Exit: 2 },
|
|
29
|
+
}));
|
|
30
|
+
jest.mock('../../host/createDeviceStore.js', () => () => ({
|
|
31
|
+
get: (key) => mockValues.get(key) ?? null,
|
|
32
|
+
set: (key, value) => mockValues.set(key, value),
|
|
33
|
+
}));
|
|
34
|
+
jest.mock('../../native/currentApp.js', () => ({
|
|
35
|
+
getCurrentApp: async () => mockApp,
|
|
36
|
+
peekCurrentApp: () => mockApp,
|
|
37
|
+
}));
|
|
38
|
+
jest.mock('../../native/callEndpoint.js', () => jest.fn());
|
|
39
|
+
|
|
40
|
+
const callEndpoint = require('../../native/callEndpoint.js');
|
|
41
|
+
const createNetworkError = require('../../test/createNetworkError.js').default;
|
|
42
|
+
|
|
43
|
+
require('./index.js');
|
|
44
|
+
|
|
45
|
+
function refusal(status) {
|
|
46
|
+
return Object.assign(new Error(`answered ${status}`), { status });
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function backlog() {
|
|
50
|
+
return JSON.parse(mockValues.get('lowdefy-shell:tracking-backlog') ?? '[]');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const locations = [{ coords: { latitude: 1, longitude: 2 }, timestamp: Date.UTC(2026, 0, 1) }];
|
|
54
|
+
|
|
55
|
+
beforeEach(() => {
|
|
56
|
+
mockValues.clear();
|
|
57
|
+
mockValues.set('lowdefy-shell:tracking', JSON.stringify({ locationEndpointId: 'trip' }));
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test('a location post that fails waits in the backlog and goes first on the next wake', async () => {
|
|
61
|
+
callEndpoint.mockRejectedValueOnce(createNetworkError());
|
|
62
|
+
await mockTasks['lowdefy-location-tracking']({ data: { locations } });
|
|
63
|
+
expect(backlog()).toHaveLength(1);
|
|
64
|
+
callEndpoint.mockResolvedValue(null);
|
|
65
|
+
await mockTasks['lowdefy-location-tracking']({ data: { locations } });
|
|
66
|
+
expect(backlog()).toEqual([]);
|
|
67
|
+
expect(callEndpoint).toHaveBeenCalledTimes(3);
|
|
68
|
+
expect(callEndpoint.mock.calls[1][0]).toMatchObject({ app: mockApp, endpointId: 'trip' });
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test('a post the server refuses is dropped, so it does not hold up the backlog', async () => {
|
|
72
|
+
mockValues.set(
|
|
73
|
+
'lowdefy-shell:tracking-backlog',
|
|
74
|
+
JSON.stringify([{ endpointId: 'trip', payload: { bad: true } }])
|
|
75
|
+
);
|
|
76
|
+
callEndpoint.mockRejectedValueOnce(refusal(400)).mockResolvedValueOnce(null);
|
|
77
|
+
const consoleError = jest.spyOn(console, 'error').mockImplementation(() => {});
|
|
78
|
+
await mockTasks['lowdefy-location-tracking']({ data: { locations } });
|
|
79
|
+
expect(backlog()).toEqual([]);
|
|
80
|
+
expect(callEndpoint).toHaveBeenCalledTimes(2);
|
|
81
|
+
// Dropped, but reported: the app's endpoint or payload needs fixing.
|
|
82
|
+
expect(consoleError).toHaveBeenCalledWith(expect.objectContaining({ status: 400 }));
|
|
83
|
+
consoleError.mockRestore();
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test('a post refused for a lost session stays for the next wake', async () => {
|
|
87
|
+
callEndpoint.mockRejectedValueOnce(refusal(401));
|
|
88
|
+
const consoleError = jest.spyOn(console, 'error').mockImplementation(() => {});
|
|
89
|
+
await mockTasks['lowdefy-location-tracking']({ data: { locations } });
|
|
90
|
+
expect(backlog()).toHaveLength(1);
|
|
91
|
+
consoleError.mockRestore();
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test('a geofence crossing raises onGeofence and posts to its endpoint', async () => {
|
|
95
|
+
mockValues.set('lowdefy-shell:tracking', JSON.stringify({ geofenceEndpointId: 'fence' }));
|
|
96
|
+
callEndpoint.mockResolvedValue(null);
|
|
97
|
+
await mockTasks['lowdefy-geofencing']({
|
|
98
|
+
data: {
|
|
99
|
+
eventType: 1,
|
|
100
|
+
region: { identifier: 'depot', latitude: 1, longitude: 2, radius: 100 },
|
|
101
|
+
},
|
|
102
|
+
});
|
|
103
|
+
expect(mockApp.triggerAppEvent).toHaveBeenCalledWith(
|
|
104
|
+
expect.objectContaining({ name: 'onGeofence' })
|
|
105
|
+
);
|
|
106
|
+
expect(callEndpoint.mock.calls[0][0]).toMatchObject({
|
|
107
|
+
endpointId: 'fence',
|
|
108
|
+
payload: { event: 'enter', region: { id: 'depot' } },
|
|
109
|
+
});
|
|
110
|
+
});
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Copyright 2020-2026 Lowdefy, Inc
|
|
3
|
+
|
|
4
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
you may not use this file except in compliance with the License.
|
|
6
|
+
You may obtain a copy of the License at
|
|
7
|
+
|
|
8
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
|
|
10
|
+
Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
See the License for the specific language governing permissions and
|
|
14
|
+
limitations under the License.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { Storage } from 'expo-sqlite/kv-store';
|
|
18
|
+
|
|
19
|
+
// The host store that lasts on the device ({ get, set, remove, keys }), over SQLite's key-value
|
|
20
|
+
// store. The core namespaces its keys per app and user.
|
|
21
|
+
function createDeviceStore() {
|
|
22
|
+
return {
|
|
23
|
+
get: (key) => Storage.getItemSync(key),
|
|
24
|
+
set: (key, value) => Storage.setItemSync(key, String(value)),
|
|
25
|
+
remove: (key) => {
|
|
26
|
+
Storage.removeItemSync(key);
|
|
27
|
+
},
|
|
28
|
+
keys: () => Storage.getAllKeysSync(),
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export default createDeviceStore;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Copyright 2020-2026 Lowdefy, Inc
|
|
3
|
+
|
|
4
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
you may not use this file except in compliance with the License.
|
|
6
|
+
You may obtain a copy of the License at
|
|
7
|
+
|
|
8
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
|
|
10
|
+
Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
See the License for the specific language governing permissions and
|
|
14
|
+
limitations under the License.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
// A host store held in memory, for the session store: it lasts as long as the app runs.
|
|
18
|
+
function createMemoryStore() {
|
|
19
|
+
const stored = new Map();
|
|
20
|
+
return {
|
|
21
|
+
get: (key) => stored.get(key) ?? null,
|
|
22
|
+
set: (key, value) => {
|
|
23
|
+
stored.set(key, String(value));
|
|
24
|
+
},
|
|
25
|
+
remove: (key) => {
|
|
26
|
+
stored.delete(key);
|
|
27
|
+
},
|
|
28
|
+
keys: () => [...stored.keys()],
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export default createMemoryStore;
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Copyright 2020-2026 Lowdefy, Inc
|
|
3
|
+
|
|
4
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
you may not use this file except in compliance with the License.
|
|
6
|
+
You may obtain a copy of the License at
|
|
7
|
+
|
|
8
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
|
|
10
|
+
Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
See the License for the specific language governing permissions and
|
|
14
|
+
limitations under the License.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import isOnline from './isOnline.js';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The shell's one online state. The NoConnection banner and the app's `online` and `offline`
|
|
21
|
+
* events (createShellHost) read it, and two sources feed it:
|
|
22
|
+
* - the phone's network, through expo-network, read while anything subscribes;
|
|
23
|
+
* - Lowdefy Dev's network injection (`setInjectedOffline`), since a simulator or emulator shares
|
|
24
|
+
* the computer's network and cannot go offline itself. A release shell never sets it.
|
|
25
|
+
*
|
|
26
|
+
* The shell is online when the phone's network is up and nothing injected offline. Subscribers
|
|
27
|
+
* hear only changes, as a browser fires `online` and `offline`.
|
|
28
|
+
*
|
|
29
|
+
* @param {Object} params
|
|
30
|
+
* @param {Object} params.network expo-network: `addNetworkStateListener` and
|
|
31
|
+
* `getNetworkStateAsync`.
|
|
32
|
+
*/
|
|
33
|
+
function createNetworkStatus({ network }) {
|
|
34
|
+
const listeners = new Set();
|
|
35
|
+
let deviceOnline = true;
|
|
36
|
+
let injectedOffline = false;
|
|
37
|
+
let online = true;
|
|
38
|
+
let stopListening = null;
|
|
39
|
+
|
|
40
|
+
function settle() {
|
|
41
|
+
const next = deviceOnline && !injectedOffline;
|
|
42
|
+
if (next === online) return;
|
|
43
|
+
online = next;
|
|
44
|
+
[...listeners].forEach((listener) => listener(online));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function setDeviceState(state) {
|
|
48
|
+
deviceOnline = isOnline(state);
|
|
49
|
+
settle();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Reads the phone's network until stopped. A change heard before the first read answers is
|
|
53
|
+
// newer than that read, so the read is then dropped.
|
|
54
|
+
function listen() {
|
|
55
|
+
let heard = false;
|
|
56
|
+
let active = true;
|
|
57
|
+
const subscription = network.addNetworkStateListener((state) => {
|
|
58
|
+
heard = true;
|
|
59
|
+
setDeviceState(state);
|
|
60
|
+
});
|
|
61
|
+
network.getNetworkStateAsync().then((state) => {
|
|
62
|
+
if (active && !heard) setDeviceState(state);
|
|
63
|
+
});
|
|
64
|
+
return () => {
|
|
65
|
+
active = false;
|
|
66
|
+
subscription.remove();
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Calls the listener with the new online state on every change; returns the function that
|
|
71
|
+
// stops it.
|
|
72
|
+
function subscribe(listener) {
|
|
73
|
+
if (listeners.size === 0) {
|
|
74
|
+
stopListening = listen();
|
|
75
|
+
}
|
|
76
|
+
listeners.add(listener);
|
|
77
|
+
return () => {
|
|
78
|
+
if (!listeners.delete(listener)) return;
|
|
79
|
+
if (listeners.size === 0) {
|
|
80
|
+
stopListening();
|
|
81
|
+
stopListening = null;
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function setInjectedOffline(offline) {
|
|
87
|
+
injectedOffline = offline;
|
|
88
|
+
settle();
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return { isOnline: () => online, setInjectedOffline, subscribe };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export default createNetworkStatus;
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Copyright 2020-2026 Lowdefy, Inc
|
|
3
|
+
|
|
4
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
you may not use this file except in compliance with the License.
|
|
6
|
+
You may obtain a copy of the License at
|
|
7
|
+
|
|
8
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
|
|
10
|
+
Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
See the License for the specific language governing permissions and
|
|
14
|
+
limitations under the License.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { jest } from '@jest/globals';
|
|
18
|
+
|
|
19
|
+
import createFakeNetwork, { OFFLINE, ONLINE } from '../test/createFakeNetwork.js';
|
|
20
|
+
import createNetworkStatus from './createNetworkStatus.js';
|
|
21
|
+
|
|
22
|
+
// Lets the first read of the phone's network answer.
|
|
23
|
+
const settle = () => new Promise((resolve) => setTimeout(resolve, 0));
|
|
24
|
+
|
|
25
|
+
test('the phone going offline and back is heard by subscribers, once per change', async () => {
|
|
26
|
+
const network = createFakeNetwork();
|
|
27
|
+
const status = createNetworkStatus({ network });
|
|
28
|
+
const listener = jest.fn();
|
|
29
|
+
status.subscribe(listener);
|
|
30
|
+
await settle();
|
|
31
|
+
expect(status.isOnline()).toBe(true);
|
|
32
|
+
network.change(OFFLINE);
|
|
33
|
+
network.change(OFFLINE);
|
|
34
|
+
expect(status.isOnline()).toBe(false);
|
|
35
|
+
network.change(ONLINE);
|
|
36
|
+
expect(listener.mock.calls).toEqual([[false], [true]]);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test('the first read of the phone network sets the state', async () => {
|
|
40
|
+
const status = createNetworkStatus({ network: createFakeNetwork({ state: OFFLINE }) });
|
|
41
|
+
const listener = jest.fn();
|
|
42
|
+
status.subscribe(listener);
|
|
43
|
+
expect(status.isOnline()).toBe(true);
|
|
44
|
+
await settle();
|
|
45
|
+
expect(status.isOnline()).toBe(false);
|
|
46
|
+
expect(listener).toHaveBeenCalledWith(false);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test('a change heard before the first read answers wins over that read', async () => {
|
|
50
|
+
const network = createFakeNetwork({ state: OFFLINE });
|
|
51
|
+
const status = createNetworkStatus({ network });
|
|
52
|
+
status.subscribe(() => undefined);
|
|
53
|
+
network.change(ONLINE);
|
|
54
|
+
network.getNetworkStateAsync = async () => OFFLINE;
|
|
55
|
+
await settle();
|
|
56
|
+
expect(status.isOnline()).toBe(true);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test('injected offline takes the shell offline while the phone is online, until it is lifted', async () => {
|
|
60
|
+
const status = createNetworkStatus({ network: createFakeNetwork() });
|
|
61
|
+
const listener = jest.fn();
|
|
62
|
+
status.subscribe(listener);
|
|
63
|
+
await settle();
|
|
64
|
+
status.setInjectedOffline(true);
|
|
65
|
+
expect(status.isOnline()).toBe(false);
|
|
66
|
+
status.setInjectedOffline(false);
|
|
67
|
+
expect(status.isOnline()).toBe(true);
|
|
68
|
+
expect(listener.mock.calls).toEqual([[false], [true]]);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test('lifting injected offline leaves the shell offline while the phone is offline', async () => {
|
|
72
|
+
const network = createFakeNetwork();
|
|
73
|
+
const status = createNetworkStatus({ network });
|
|
74
|
+
status.subscribe(() => undefined);
|
|
75
|
+
await settle();
|
|
76
|
+
status.setInjectedOffline(true);
|
|
77
|
+
network.change(OFFLINE);
|
|
78
|
+
status.setInjectedOffline(false);
|
|
79
|
+
expect(status.isOnline()).toBe(false);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test('the phone network is read only while something subscribes', async () => {
|
|
83
|
+
const network = createFakeNetwork();
|
|
84
|
+
const status = createNetworkStatus({ network });
|
|
85
|
+
expect(network.listening()).toBe(false);
|
|
86
|
+
const first = status.subscribe(() => undefined);
|
|
87
|
+
const second = status.subscribe(() => undefined);
|
|
88
|
+
expect(network.listening()).toBe(true);
|
|
89
|
+
first();
|
|
90
|
+
first();
|
|
91
|
+
expect(network.listening()).toBe(true);
|
|
92
|
+
second();
|
|
93
|
+
expect(network.listening()).toBe(false);
|
|
94
|
+
});
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Copyright 2020-2026 Lowdefy, Inc
|
|
3
|
+
|
|
4
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
you may not use this file except in compliance with the License.
|
|
6
|
+
You may obtain a copy of the License at
|
|
7
|
+
|
|
8
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
|
|
10
|
+
Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
See the License for the specific language governing permissions and
|
|
14
|
+
limitations under the License.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { router } from 'expo-router';
|
|
18
|
+
|
|
19
|
+
import toRouterHref from '../navigation/toRouterHref.js';
|
|
20
|
+
|
|
21
|
+
// The core's router contract (push, replace, back) on Expo Router.
|
|
22
|
+
function createRouterAdapter({ session }) {
|
|
23
|
+
function href(target) {
|
|
24
|
+
return toRouterHref({ ...target, root: session.getState().root });
|
|
25
|
+
}
|
|
26
|
+
return {
|
|
27
|
+
push: (target) => router.push(href(target)),
|
|
28
|
+
replace: (target) => router.replace(href(target)),
|
|
29
|
+
back: () => router.back(),
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export default createRouterAdapter;
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Copyright 2020-2026 Lowdefy, Inc
|
|
3
|
+
|
|
4
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
you may not use this file except in compliance with the License.
|
|
6
|
+
You may obtain a copy of the License at
|
|
7
|
+
|
|
8
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
|
|
10
|
+
Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
See the License for the specific language governing permissions and
|
|
14
|
+
limitations under the License.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const BUILD_HEADER = 'x-lowdefy-build';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The fetch the core's requests and endpoint calls go through.
|
|
21
|
+
*
|
|
22
|
+
* - A page request carries the build of the root the shell holds (the core stamps the build it was
|
|
23
|
+
* created with; the shell keeps it current without rebuilding the core).
|
|
24
|
+
* - The server refuses a page request from another build with a 409. The shell then syncs: it
|
|
25
|
+
* refetches the root and the changed pages. When the request's own page did not change with the
|
|
26
|
+
* build, its payload is still the shape the new build expects, so the request is retried once,
|
|
27
|
+
* stamped with the new build. When the page changed, the 409 goes to the core, which hands it to
|
|
28
|
+
* onBuildChanged: the screen is rebuilt on the new page and runs its requests again.
|
|
29
|
+
* - A request sent with `keepBuild: true` (the offline queue's, stamped with the build it was
|
|
30
|
+
* queued under) keeps its stamp, so a request from an older build gets the 409 and its check.
|
|
31
|
+
*
|
|
32
|
+
* @param {Object} params
|
|
33
|
+
* @param {Function} params.fetch The Fetch API, sending the shell header (withShellHeader).
|
|
34
|
+
* @param {Object} params.session The app session (createAppSession).
|
|
35
|
+
*/
|
|
36
|
+
function createShellFetch({ fetch, session }) {
|
|
37
|
+
return async function shellFetch(url, { keepBuild = false, ...init } = {}) {
|
|
38
|
+
const headers = { ...init.headers };
|
|
39
|
+
const stamped = headers[BUILD_HEADER] !== undefined;
|
|
40
|
+
if (stamped && !keepBuild) {
|
|
41
|
+
headers[BUILD_HEADER] = session.getState().buildId ?? headers[BUILD_HEADER];
|
|
42
|
+
}
|
|
43
|
+
const res = await fetch(url, { ...init, headers });
|
|
44
|
+
if (res.status !== 409 || !stamped) {
|
|
45
|
+
return res;
|
|
46
|
+
}
|
|
47
|
+
const before = session.getRequestPageEntry(url);
|
|
48
|
+
try {
|
|
49
|
+
await session.sync();
|
|
50
|
+
} catch (error) {
|
|
51
|
+
return res;
|
|
52
|
+
}
|
|
53
|
+
const after = session.getRequestPageEntry(url);
|
|
54
|
+
if (before === null || after === null || before.hash !== after.hash) {
|
|
55
|
+
return res;
|
|
56
|
+
}
|
|
57
|
+
const buildId = session.getState().buildId ?? headers[BUILD_HEADER];
|
|
58
|
+
return fetch(url, { ...init, headers: { ...headers, [BUILD_HEADER]: buildId } });
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export default createShellFetch;
|