@lowdefy/shell 3.23.2 → 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.
Files changed (356) hide show
  1. package/LICENSE +183 -183
  2. package/README.md +364 -2
  3. package/app/(t0,t1,t2,t3,t4)/[...path].js +38 -0
  4. package/app/(t0,t1,t2,t3,t4)/_layout.js +48 -0
  5. package/app/(t0,t1,t2,t3,t4)/index.js +38 -0
  6. package/app/+native-intent.js +39 -0
  7. package/app/_layout.js +36 -0
  8. package/app.config.ts +183 -0
  9. package/appLinks.js +65 -0
  10. package/applyShellPlugins.js +43 -0
  11. package/authCallbackIntentFilters.js +46 -0
  12. package/babel.config.js +25 -0
  13. package/checkShellConfig.js +172 -0
  14. package/checkShellPlugins.js +74 -0
  15. package/getPrivacyManifest.js +39 -0
  16. package/metro.config.js +73 -0
  17. package/native/LowdefyAppShortcuts.swift.template +84 -0
  18. package/native/withAppShortcuts.js +60 -0
  19. package/native-fingerprint.json +16 -0
  20. package/package.json +120 -40
  21. package/profiles.js +607 -0
  22. package/readShellConfig.js +36 -0
  23. package/resolveShellPlugins.js +109 -0
  24. package/runtimeVersion.js +112 -0
  25. package/scripts/checkReleaseBundle.mjs +358 -0
  26. package/scripts/createDayjsLocales.mjs +67 -0
  27. package/scripts/createShellAssets.mjs +54 -0
  28. package/scripts/createShellVersion.mjs +83 -0
  29. package/scripts/createTypeManifest.mjs +205 -0
  30. package/shell.dev.json +11 -0
  31. package/shellVariant.js +126 -0
  32. package/src/ShellContext.js +32 -0
  33. package/src/actions/CopyToClipboard.js +29 -0
  34. package/src/actions/CopyToClipboard.test.js +33 -0
  35. package/src/actions/GeolocationCurrentPosition.js +61 -0
  36. package/src/actions/GeolocationCurrentPosition.test.js +79 -0
  37. package/src/actions/GetLocalStorage.js +36 -0
  38. package/src/actions/RemoveLocalStorage.js +31 -0
  39. package/src/actions/ScrollTo.js +66 -0
  40. package/src/actions/ScrollTo.test.js +83 -0
  41. package/src/actions/SetFocus.js +33 -0
  42. package/src/actions/SetFocus.test.js +49 -0
  43. package/src/actions/SetLocalStorage.js +34 -0
  44. package/src/actions/localStorage.test.js +68 -0
  45. package/src/actions/measureInScrollView.js +36 -0
  46. package/src/actions/nativeActions.js +41 -0
  47. package/src/auth/AuthButton.js +44 -0
  48. package/src/auth/AuthGate.js +57 -0
  49. package/src/auth/AuthLink.js +43 -0
  50. package/src/auth/LockScreen.js +67 -0
  51. package/src/auth/LockScreen.test.js +37 -0
  52. package/src/auth/SignInScreen.js +268 -0
  53. package/src/auth/SignInScreen.test.js +187 -0
  54. package/src/auth/createAppAuth.js +325 -0
  55. package/src/auth/createAppAuth.test.js +424 -0
  56. package/src/auth/createAuthApi.js +115 -0
  57. package/src/auth/createAuthApi.test.js +138 -0
  58. package/src/auth/createCoreAuth.js +96 -0
  59. package/src/auth/createCoreAuth.test.js +188 -0
  60. package/src/auth/createDeviceKey.js +62 -0
  61. package/src/auth/createDeviceKey.test.js +54 -0
  62. package/src/auth/createSessionStores.js +45 -0
  63. package/src/auth/createTokenStore.js +103 -0
  64. package/src/auth/createTokenStore.test.js +116 -0
  65. package/src/auth/getAuthSettings.js +24 -0
  66. package/src/auth/getAuthView.js +39 -0
  67. package/src/auth/getAuthView.test.js +52 -0
  68. package/src/auth/getSheetReturnUrl.js +35 -0
  69. package/src/auth/getTokenStoreOptions.js +46 -0
  70. package/src/auth/isBackgroundShell.js +26 -0
  71. package/src/auth/isSheetReturnLink.js +35 -0
  72. package/src/auth/parseQuery.js +39 -0
  73. package/src/auth/purgeAppData.js +80 -0
  74. package/src/auth/purgeAppData.test.js +104 -0
  75. package/src/auth/shouldLock.js +26 -0
  76. package/src/auth/signInWithSheet.js +97 -0
  77. package/src/auth/signInWithSheet.test.js +205 -0
  78. package/src/auth/styles.js +78 -0
  79. package/src/auth/unregisterPushToken.js +62 -0
  80. package/src/auth/unregisterPushToken.test.js +67 -0
  81. package/src/auth/usesBiometricUnlock.js +23 -0
  82. package/src/auth/watchSession.js +47 -0
  83. package/src/auth/withoutCookies.js +26 -0
  84. package/src/cache/createSqliteCacheStore.js +113 -0
  85. package/src/cache/createSqliteCacheStore.test.js +93 -0
  86. package/src/cache/getCacheKey.js +25 -0
  87. package/src/cache/getLastCacheKeyName.js +22 -0
  88. package/src/components/AppProvider.js +118 -0
  89. package/src/components/Frame.js +116 -0
  90. package/src/components/Frame.test.js +101 -0
  91. package/src/components/LoadingScreen.js +35 -0
  92. package/src/components/MenuButton.js +121 -0
  93. package/src/components/MenuButton.test.js +74 -0
  94. package/src/components/MessageScreen.js +51 -0
  95. package/src/components/NavigationTheme.js +33 -0
  96. package/src/components/NoConnectionBanner.js +51 -0
  97. package/src/components/NoConnectionBanner.test.js +73 -0
  98. package/src/components/OtherPageScreen.js +95 -0
  99. package/src/components/OtherPageScreen.test.js +74 -0
  100. package/src/components/PageScreen.js +254 -0
  101. package/src/components/PageScreen.test.js +492 -0
  102. package/src/components/RouteErrorScreen.js +47 -0
  103. package/src/components/ShellRoot.js +34 -0
  104. package/src/components/ShellRoot.test.js +62 -0
  105. package/src/components/ShellThemeProvider.js +45 -0
  106. package/src/components/SwitchToTab.js +36 -0
  107. package/src/components/SwitchToTab.test.js +63 -0
  108. package/src/components/TabIconImages.js +72 -0
  109. package/src/components/UpdateScreen.js +40 -0
  110. package/src/components/getNavigationTheme.js +44 -0
  111. package/src/components/getNavigationTheme.test.js +41 -0
  112. package/src/components/styles.js +50 -0
  113. package/src/components/useSurfaceTokens.js +47 -0
  114. package/src/config/getAppApiBase.js +34 -0
  115. package/src/config/getShellConfig.js +37 -0
  116. package/src/config/getShellConfig.test.js +80 -0
  117. package/src/createShellApp.js +126 -0
  118. package/src/dev/DevShell.js +239 -0
  119. package/src/dev/DevShell.test.js +233 -0
  120. package/src/dev/connect/ConfirmSheet.js +71 -0
  121. package/src/dev/connect/ConnectScreen.js +117 -0
  122. package/src/dev/connect/QrScanner.js +89 -0
  123. package/src/dev/connection/createDevShellConfig.js +47 -0
  124. package/src/dev/connection/createDevTransport.js +51 -0
  125. package/src/dev/connection/createDevTransport.test.js +87 -0
  126. package/src/dev/connection/createServerList.js +48 -0
  127. package/src/dev/connection/isLoopbackHost.js +25 -0
  128. package/src/dev/connection/pairedShellApp.test.js +104 -0
  129. package/src/dev/connection/parseDevLink.js +66 -0
  130. package/src/dev/connection/parseDevLink.test.js +97 -0
  131. package/src/dev/connection/parseQuery.js +33 -0
  132. package/src/dev/connection/parseServerInput.js +40 -0
  133. package/src/dev/connection/parseServerUrl.js +45 -0
  134. package/src/dev/devStyles.js +97 -0
  135. package/src/dev/index.js +21 -0
  136. package/src/dev/inject/createDeviceInjections.js +188 -0
  137. package/src/dev/inject/createDeviceInjections.test.js +224 -0
  138. package/src/dev/inject/createOfflineFetch.js +35 -0
  139. package/src/dev/inject/createOfflineFetch.test.js +68 -0
  140. package/src/dev/inject/injectPush.js +60 -0
  141. package/src/dev/inject/injectShare.js +55 -0
  142. package/src/dev/inject/withInjectedDevice.js +98 -0
  143. package/src/dev/menu/BlockGallery.js +33 -0
  144. package/src/dev/menu/DevMenu.js +121 -0
  145. package/src/dev/menu/createLogBuffer.js +83 -0
  146. package/src/dev/menu/watchShake.js +52 -0
  147. package/src/dev/menu/watchShake.test.js +61 -0
  148. package/src/dev/recorder/createNativeRecorder.js +204 -0
  149. package/src/dev/recorder/createNativeRecorder.test.js +307 -0
  150. package/src/dev/redirectDevLink.js +30 -0
  151. package/src/dev/redirectDevLink.test.js +49 -0
  152. package/src/dev/steps/createStepRunner.js +184 -0
  153. package/src/dev/steps/createStepRunner.test.js +262 -0
  154. package/src/dev/steps/findBlock.js +78 -0
  155. package/src/dev/steps/isContextSettled.js +37 -0
  156. package/src/dev/steps/waitForSettle.js +39 -0
  157. package/src/dev/steps/waitUntil.js +40 -0
  158. package/src/dev/tab/DevTabClient.js +97 -0
  159. package/src/dev/tab/answerEval.js +39 -0
  160. package/src/dev/tab/answerInspect.js +45 -0
  161. package/src/dev/tab/createDevTabClient.js +272 -0
  162. package/src/dev/tab/createDevTabClient.test.js +317 -0
  163. package/src/dev/tab/describeTabError.js +35 -0
  164. package/src/dev/tab/devClientMarker.js +22 -0
  165. package/src/dev/tab/devClientMarker.test.js +23 -0
  166. package/src/dev/tab/findPageContext.js +43 -0
  167. package/src/dev/tab/getShownPage.js +42 -0
  168. package/src/dev/tab/readEventStream.js +68 -0
  169. package/src/dev/tab/reloadApp.js +42 -0
  170. package/src/dev/tab/reloadApp.test.js +115 -0
  171. package/src/device/DeviceHosts.js +32 -0
  172. package/src/device/basic/createFilesFeature.js +41 -0
  173. package/src/device/basic/createHapticsFeature.js +46 -0
  174. package/src/device/basic/createKeepAwakeFeature.js +37 -0
  175. package/src/device/basic/createPhotosFeature.js +40 -0
  176. package/src/device/basic/createRequestQueueFeature.js +37 -0
  177. package/src/device/basic/createShareFeature.js +63 -0
  178. package/src/device/basic/createShareFeature.test.js +47 -0
  179. package/src/device/basic/createSpeechFeature.js +43 -0
  180. package/src/device/basic/createUploadFeature.js +41 -0
  181. package/src/device/basic/toImagePickerOptions.js +41 -0
  182. package/src/device/basic/toPickedFile.js +54 -0
  183. package/src/device/calendar/index.js +68 -0
  184. package/src/device/contacts/index.js +67 -0
  185. package/src/device/createDevice.js +65 -0
  186. package/src/device/device.js +27 -0
  187. package/src/device/field/MapView.js +93 -0
  188. package/src/device/field/ScanCodeSheet.js +86 -0
  189. package/src/device/field/ScannerView.js +52 -0
  190. package/src/device/field/createAudioFeature.js +70 -0
  191. package/src/device/field/createBrightnessFeature.js +40 -0
  192. package/src/device/field/createCameraFeature.js +56 -0
  193. package/src/device/field/createDocumentScannerFeature.js +51 -0
  194. package/src/device/field/createLocationFeature.js +39 -0
  195. package/src/device/field/createScanRequests.js +55 -0
  196. package/src/device/field/index.js +39 -0
  197. package/src/device/field/scanRequests.js +22 -0
  198. package/src/device/injected.js +60 -0
  199. package/src/device/injected.test.js +39 -0
  200. package/src/device/nfc/index.js +89 -0
  201. package/src/device/nfc/toNfcRecord.js +44 -0
  202. package/src/device/nfc/toNfcRecord.test.js +48 -0
  203. package/src/device/tracking/createBacklog.js +63 -0
  204. package/src/device/tracking/createBacklog.test.js +47 -0
  205. package/src/device/tracking/index.js +211 -0
  206. package/src/device/tracking/index.test.js +110 -0
  207. package/src/host/createDeviceStore.js +32 -0
  208. package/src/host/createMemoryStore.js +32 -0
  209. package/src/host/createNetworkStatus.js +94 -0
  210. package/src/host/createNetworkStatus.test.js +94 -0
  211. package/src/host/createRouterAdapter.js +33 -0
  212. package/src/host/createShellFetch.js +62 -0
  213. package/src/host/createShellFetch.test.js +165 -0
  214. package/src/host/createShellGlobals.js +62 -0
  215. package/src/host/createShellHost.js +83 -0
  216. package/src/host/createShellHost.test.js +36 -0
  217. package/src/host/getOrigin.js +31 -0
  218. package/src/host/isNetworkError.js +24 -0
  219. package/src/host/isOnline.js +22 -0
  220. package/src/host/networkStatus.js +24 -0
  221. package/src/host/readServerError.js +45 -0
  222. package/src/host/readServerError.test.js +85 -0
  223. package/src/host/reportShellError.js +47 -0
  224. package/src/host/reportShellError.test.js +79 -0
  225. package/src/host/withNetworkErrors.js +38 -0
  226. package/src/host/withNetworkErrors.test.js +61 -0
  227. package/src/host/withShellHeader.js +25 -0
  228. package/src/host/withShellHeader.test.js +28 -0
  229. package/src/js/evaluateJsEntries.js +43 -0
  230. package/src/js/evaluateJsEntries.test.js +48 -0
  231. package/src/locale/createShellLocale.js +112 -0
  232. package/src/locale/createShellLocale.test.js +74 -0
  233. package/src/locale/dayjsLocales.js +165 -0
  234. package/src/locale/getDeviceLocales.js +23 -0
  235. package/src/locale/loadDayjsLocale.js +32 -0
  236. package/src/locale/resolveActiveLocale.js +46 -0
  237. package/src/locale/resolveActiveLocale.test.js +40 -0
  238. package/src/native/backgroundFetch.js +97 -0
  239. package/src/native/callEndpoint.js +63 -0
  240. package/src/native/createAppEventTrigger.js +70 -0
  241. package/src/native/currentApp.js +49 -0
  242. package/src/native/getPagePath.js +32 -0
  243. package/src/native/openPath.js +55 -0
  244. package/src/native/openPath.test.js +39 -0
  245. package/src/native/parseShellLink.js +38 -0
  246. package/src/native/parseShellLink.test.js +29 -0
  247. package/src/native/startAppServices.js +94 -0
  248. package/src/native/startAppServices.test.js +97 -0
  249. package/src/native/startShareIn.js +59 -0
  250. package/src/native/startShortcuts.js +88 -0
  251. package/src/native/startShortcuts.test.js +59 -0
  252. package/src/native/toSharePayload.js +47 -0
  253. package/src/native/toSharePayload.test.js +47 -0
  254. package/src/native/whenAppReady.js +48 -0
  255. package/src/navigation/SlotContext.js +24 -0
  256. package/src/navigation/frameSlots.js +27 -0
  257. package/src/navigation/getFrame.js +30 -0
  258. package/src/navigation/getInitialScreenOptions.js +63 -0
  259. package/src/navigation/getInitialScreenOptions.test.js +66 -0
  260. package/src/navigation/getPresentation.js +28 -0
  261. package/src/navigation/getSlotRootPath.js +27 -0
  262. package/src/navigation/getStackOptions.js +185 -0
  263. package/src/navigation/getTabHref.js +45 -0
  264. package/src/navigation/getTabIcons.js +43 -0
  265. package/src/navigation/isPageUnderHeader.js +46 -0
  266. package/src/navigation/navigation.test.js +342 -0
  267. package/src/navigation/toAppLinkPath.js +43 -0
  268. package/src/navigation/toAppLinkPath.test.js +53 -0
  269. package/src/navigation/toRouterHref.js +29 -0
  270. package/src/notifications/createNotificationsFeature.js +61 -0
  271. package/src/notifications/getCategoryAction.js +31 -0
  272. package/src/notifications/handleNotificationResponse.js +82 -0
  273. package/src/notifications/registerPushToken.js +71 -0
  274. package/src/notifications/startNotifications.js +114 -0
  275. package/src/notifications/toCategoryActions.js +42 -0
  276. package/src/notifications/toNotificationEvent.js +36 -0
  277. package/src/notifications/toScheduleRequest.js +62 -0
  278. package/src/plugins/appTypes.js +28 -0
  279. package/src/plugins/prefixTypes.js +28 -0
  280. package/src/plugins/shellPlugins.json +15 -0
  281. package/src/plugins/typeManifest.js +789 -0
  282. package/src/plugins/types.js +64 -0
  283. package/src/plugins/types.test.js +173 -0
  284. package/src/polyfills/installGetRandomValues.js +28 -0
  285. package/src/queue/createRequestQueue.js +198 -0
  286. package/src/queue/createRequestQueue.test.js +215 -0
  287. package/src/queue/createSqliteQueueStore.js +121 -0
  288. package/src/queue/summarizeQueue.js +39 -0
  289. package/src/session/createAppSession.js +323 -0
  290. package/src/session/createAppSession.test.js +391 -0
  291. package/src/session/getPageView.js +59 -0
  292. package/src/session/getPageView.test.js +137 -0
  293. package/src/sync/encodePagePath.js +27 -0
  294. package/src/sync/fetchPage.js +72 -0
  295. package/src/sync/fetchPage.test.js +84 -0
  296. package/src/sync/fetchRoot.js +43 -0
  297. package/src/sync/fetchUser.js +33 -0
  298. package/src/sync/runLimited.js +36 -0
  299. package/src/sync/toRootConfig.js +35 -0
  300. package/src/test/createFakeNetwork.js +40 -0
  301. package/src/test/createFakeSecureStore.js +40 -0
  302. package/src/test/createFakeServer.js +98 -0
  303. package/src/test/createMemoryCacheStore.js +59 -0
  304. package/src/test/createNetworkError.js +24 -0
  305. package/src/test/expoImage.js +42 -0
  306. package/src/test/expoNotifications.js +40 -0
  307. package/src/test/expoQuickActions.js +22 -0
  308. package/src/test/expoSpeechRecognition.js +31 -0
  309. package/src/test/expoTaskManager.js +25 -0
  310. package/src/test/expoUi.js +55 -0
  311. package/src/test/expoUiBottomSheet.js +64 -0
  312. package/src/test/expoUiDateTimePicker.js +26 -0
  313. package/src/test/expoUiMenu.js +46 -0
  314. package/src/test/expoUiSegmentedControl.js +47 -0
  315. package/src/test/expoUiSwiftUi.js +62 -0
  316. package/src/test/expoUiSwiftUiModifiers.js +23 -0
  317. package/src/test/expoWebBrowser.js +23 -0
  318. package/src/test/fixtures.js +98 -0
  319. package/src/test/nativeModule.js +33 -0
  320. package/src/test/renderPageScreen.js +109 -0
  321. package/src/types/checkTypeSet.js +39 -0
  322. package/src/types/collectPageTypes.js +49 -0
  323. package/src/types/collectPageTypes.test.js +90 -0
  324. package/src/types/findMissingPageTypes.js +40 -0
  325. package/src/version/compareVersions.js +38 -0
  326. package/src/version/isShellTooOld.js +30 -0
  327. package/src/version/shellVersion.js +21 -0
  328. package/src/version/shellVersion.test.js +32 -0
  329. package/src/widgets/fetchWidgetData.js +59 -0
  330. package/src/widgets/index.js +156 -0
  331. package/src/widgets/refreshWidgets.js +31 -0
  332. package/withExcludedNativeModules.js +93 -0
  333. package/dist/location.js +0 -33
  334. package/dist/public/apple-touch-icon.png +0 -0
  335. package/dist/public/icon-32.png +0 -0
  336. package/dist/public/icon-512.png +0 -0
  337. package/dist/public/icon.svg +0 -17
  338. package/dist/public/logo-dark-theme.png +0 -0
  339. package/dist/public/logo-light-theme.png +0 -0
  340. package/dist/public/logo-square-dark-theme.png +0 -0
  341. package/dist/public/logo-square-light-theme.png +0 -0
  342. package/dist/public/manifest.webmanifest +0 -16
  343. package/dist/shell/246.93432ff68186aae6c87b.js +0 -2
  344. package/dist/shell/246.93432ff68186aae6c87b.js.LICENSE.txt +0 -8
  345. package/dist/shell/454.b597ca21366afda000af.js +0 -2
  346. package/dist/shell/454.b597ca21366afda000af.js.LICENSE.txt +0 -14
  347. package/dist/shell/484.d645e6c09079f45ce044.js +0 -1
  348. package/dist/shell/706.a66931e02a549fd269bd.js +0 -2
  349. package/dist/shell/706.a66931e02a549fd269bd.js.LICENSE.txt +0 -8
  350. package/dist/shell/9.78be8c1b6c3986f404fc.js +0 -2
  351. package/dist/shell/9.78be8c1b6c3986f404fc.js.LICENSE.txt +0 -45
  352. package/dist/shell/922.43b7a3ebe22ef05b3049.js +0 -2
  353. package/dist/shell/922.43b7a3ebe22ef05b3049.js.LICENSE.txt +0 -14
  354. package/dist/shell/index.html +0 -57
  355. package/dist/shell/main.bde87d1433f0625faed6.js +0 -1
  356. package/dist/shell/runtime.1ead93159ba56c05b969.js +0 -1
@@ -0,0 +1,43 @@
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 React from 'react';
18
+ import { Pressable, Text } from 'react-native';
19
+
20
+ import styles from './styles.js';
21
+ import useSurfaceTokens from '../components/useSurfaceTokens.js';
22
+
23
+ function AuthLink({ disabled = false, onPress, style, testID, title }) {
24
+ const tokens = useSurfaceTokens();
25
+ return (
26
+ <Pressable
27
+ accessibilityRole="link"
28
+ accessibilityState={{ disabled }}
29
+ disabled={disabled}
30
+ onPress={onPress}
31
+ style={style}
32
+ testID={testID}
33
+ >
34
+ <Text
35
+ style={[styles.link, { color: disabled ? tokens.colorTertiaryLabel : tokens.colorTint }]}
36
+ >
37
+ {title}
38
+ </Text>
39
+ </Pressable>
40
+ );
41
+ }
42
+
43
+ export default AuthLink;
@@ -0,0 +1,67 @@
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 React, { useCallback, useEffect, useState } from 'react';
18
+ import { View, Text } from 'react-native';
19
+
20
+ import AuthButton from './AuthButton.js';
21
+ import AuthLink from './AuthLink.js';
22
+ import styles from './styles.js';
23
+ import useSurfaceTokens from '../components/useSurfaceTokens.js';
24
+
25
+ /**
26
+ * The lock screen of an app with biometric unlock: no page shows until Face ID or a fingerprint
27
+ * unlocks it. It asks at once, and again on Unlock. "Sign in again" signs out, for a person whose
28
+ * biometrics no longer open the app.
29
+ */
30
+ function LockScreen({ appName, auth }) {
31
+ const tokens = useSurfaceTokens();
32
+ const [busy, setBusy] = useState(false);
33
+
34
+ const unlock = useCallback(async () => {
35
+ setBusy(true);
36
+ try {
37
+ await auth.unlock();
38
+ } finally {
39
+ setBusy(false);
40
+ }
41
+ }, [auth]);
42
+
43
+ useEffect(() => {
44
+ unlock();
45
+ }, [unlock]);
46
+
47
+ return (
48
+ <View
49
+ style={[styles.screen, { backgroundColor: tokens.colorGroupedBackground }]}
50
+ testID="shell-lock"
51
+ >
52
+ <Text accessibilityRole="header" style={[styles.appName, { color: tokens.colorLabel }]}>
53
+ {appName}
54
+ </Text>
55
+ <AuthButton disabled={busy} onPress={unlock} testID="lock-unlock" title="Unlock" />
56
+ <AuthLink
57
+ disabled={busy}
58
+ onPress={() => auth.signOut()}
59
+ style={styles.centered}
60
+ testID="lock-sign-in-again"
61
+ title="Sign in again"
62
+ />
63
+ </View>
64
+ );
65
+ }
66
+
67
+ export default LockScreen;
@@ -0,0 +1,37 @@
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 React from 'react';
18
+ import { jest } from '@jest/globals';
19
+ import { fireEvent, render, screen } from '@testing-library/react-native';
20
+
21
+ import LockScreen from './LockScreen.js';
22
+
23
+ test('the lock screen asks to unlock at once, and again on Unlock', async () => {
24
+ const auth = { unlock: jest.fn(async () => false), signOut: jest.fn() };
25
+ await render(<LockScreen appName="Field" auth={auth} />);
26
+ expect(screen.getByText('Field')).toBeTruthy();
27
+ expect(auth.unlock).toHaveBeenCalledTimes(1);
28
+ await fireEvent.press(screen.getByTestId('lock-unlock'));
29
+ expect(auth.unlock).toHaveBeenCalledTimes(2);
30
+ });
31
+
32
+ test('Sign in again signs out', async () => {
33
+ const auth = { unlock: jest.fn(async () => false), signOut: jest.fn() };
34
+ await render(<LockScreen appName="Field" auth={auth} />);
35
+ await fireEvent.press(screen.getByTestId('lock-sign-in-again'));
36
+ expect(auth.signOut).toHaveBeenCalledTimes(1);
37
+ });
@@ -0,0 +1,268 @@
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 React, { useCallback, useEffect, useRef, useState } from 'react';
18
+ import { KeyboardAvoidingView, ScrollView, Text, TextInput, View } from 'react-native';
19
+
20
+ import AuthButton from './AuthButton.js';
21
+ import AuthLink from './AuthLink.js';
22
+ import isNetworkError from '../host/isNetworkError.js';
23
+ import reportShellError from '../host/reportShellError.js';
24
+ import styles from './styles.js';
25
+ import useSurfaceTokens from '../components/useSurfaceTokens.js';
26
+
27
+ const CODE_LENGTH = 6;
28
+ const RESEND_SECONDS = 30;
29
+
30
+ const MESSAGES = {
31
+ sent: 'If this email can sign in, a code is on its way.',
32
+ invalid: 'That code is not right, or it has expired.',
33
+ rateLimited: 'Too many tries. Wait a minute, then try again.',
34
+ tooManyCodes:
35
+ 'Too many codes were sent to this email. Use the newest code in your email, or try again later.',
36
+ sheetFailed: 'Sign-in did not finish. Try again.',
37
+ unreachable: "Could not reach the app's server. Check your connection and try again.",
38
+ failed: 'Sign-in did not work. Try again later.',
39
+ };
40
+
41
+ /**
42
+ * Sign-in for an app with auth, before any page config can be fetched. By default the person
43
+ * types their email, then the 6-digit code from the email (the phone offers it from Mail or
44
+ * Messages). "Other ways to sign in", an app without email codes (mobile.auth.emailCode: false)
45
+ * and a person who must pass a second factor go to the app's own sign-in page in a system sheet.
46
+ *
47
+ * The screen never says whether an address can sign in: after Continue it always says a code is
48
+ * on its way, if the address can sign in.
49
+ *
50
+ * @param {Object} props
51
+ * @param {string} props.appName The app's name.
52
+ * @param {Object} props.auth The app's auth (createAppAuth).
53
+ * @param {{ emailCode: boolean }} props.settings The app's mobile.auth.
54
+ */
55
+ function SignInScreen({ appName, auth, settings }) {
56
+ const tokens = useSurfaceTokens();
57
+ const labelStyle = [styles.label, { color: tokens.colorLabel }];
58
+ const inputStyle = [
59
+ styles.input,
60
+ { backgroundColor: tokens.colorCell, color: tokens.colorLabel },
61
+ ];
62
+ const [step, setStep] = useState('email');
63
+ const [email, setEmail] = useState('');
64
+ const [code, setCode] = useState('');
65
+ const [busy, setBusy] = useState(false);
66
+ const [message, setMessage] = useState(null);
67
+ const [error, setError] = useState(null);
68
+ const [resendIn, setResendIn] = useState(0);
69
+ const sheetOpened = useRef(false);
70
+
71
+ useEffect(() => {
72
+ if (resendIn <= 0) return undefined;
73
+ const timer = setTimeout(() => setResendIn((seconds) => seconds - 1), 1000);
74
+ return () => clearTimeout(timer);
75
+ }, [resendIn]);
76
+
77
+ const run = useCallback(async (task) => {
78
+ setBusy(true);
79
+ setError(null);
80
+ try {
81
+ await task();
82
+ } catch (taskError) {
83
+ // Only a call that never reached the server is a connection problem. Anything else (the
84
+ // server answered with an error, or signed in without a session token) is a fault.
85
+ if (isNetworkError(taskError)) {
86
+ setError(MESSAGES.unreachable);
87
+ return;
88
+ }
89
+ reportShellError({ error: taskError });
90
+ setError(MESSAGES.failed);
91
+ } finally {
92
+ setBusy(false);
93
+ }
94
+ }, []);
95
+
96
+ const openSheet = useCallback(async () => {
97
+ const result = await auth.signInWithSheet();
98
+ if (result.status === 'failed') {
99
+ setError(MESSAGES.sheetFailed);
100
+ }
101
+ }, [auth]);
102
+
103
+ useEffect(() => {
104
+ if (settings.emailCode || sheetOpened.current) return;
105
+ sheetOpened.current = true;
106
+ run(openSheet);
107
+ }, [openSheet, run, settings.emailCode]);
108
+
109
+ function sendCode() {
110
+ run(async () => {
111
+ const result = await auth.sendCode({ email: email.trim() });
112
+ if (result.status === 'unavailable') {
113
+ await openSheet();
114
+ return;
115
+ }
116
+ setStep('code');
117
+ setCode('');
118
+ setResendIn(RESEND_SECONDS);
119
+ // The address's cap is full, which anyone who knows it can do. A code it was sent while
120
+ // filling still works, so the person is asked for it rather than kept on the email step.
121
+ setMessage(result.status === 'rateLimited' ? MESSAGES.tooManyCodes : MESSAGES.sent);
122
+ });
123
+ }
124
+
125
+ function submitCode(otp) {
126
+ run(async () => {
127
+ const result = await auth.signInWithCode({ email: email.trim(), otp });
128
+ if (result.status === 'twoFactor') {
129
+ await openSheet();
130
+ return;
131
+ }
132
+ if (result.status === 'invalid' || result.status === 'rateLimited') {
133
+ setCode('');
134
+ setError(MESSAGES[result.status]);
135
+ }
136
+ });
137
+ }
138
+
139
+ function changeCode(text) {
140
+ const digits = text.replace(/\D/g, '').slice(0, CODE_LENGTH);
141
+ setCode(digits);
142
+ if (digits.length === CODE_LENGTH && !busy) {
143
+ submitCode(digits);
144
+ }
145
+ }
146
+
147
+ function changeEmail() {
148
+ setStep('email');
149
+ setCode('');
150
+ setMessage(null);
151
+ setError(null);
152
+ }
153
+
154
+ let form;
155
+ if (!settings.emailCode) {
156
+ form = (
157
+ <AuthButton
158
+ disabled={busy}
159
+ onPress={() => run(openSheet)}
160
+ testID="sign-in-sheet"
161
+ title="Sign in"
162
+ />
163
+ );
164
+ } else if (step === 'email') {
165
+ form = (
166
+ <>
167
+ <Text style={labelStyle}>Email</Text>
168
+ <TextInput
169
+ autoCapitalize="none"
170
+ autoComplete="email"
171
+ autoCorrect={false}
172
+ editable={!busy}
173
+ inputMode="email"
174
+ keyboardType="email-address"
175
+ onChangeText={setEmail}
176
+ onSubmitEditing={sendCode}
177
+ placeholderTextColor={tokens.colorTertiaryLabel}
178
+ returnKeyType="next"
179
+ style={inputStyle}
180
+ testID="sign-in-email"
181
+ textContentType="emailAddress"
182
+ value={email}
183
+ />
184
+ <AuthButton
185
+ disabled={busy || email.trim().length === 0}
186
+ onPress={sendCode}
187
+ testID="sign-in-continue"
188
+ title="Continue"
189
+ />
190
+ </>
191
+ );
192
+ } else {
193
+ form = (
194
+ <>
195
+ <Text
196
+ style={[styles.message, { color: tokens.colorSecondaryLabel }]}
197
+ testID="sign-in-message"
198
+ >
199
+ {message}
200
+ </Text>
201
+ <Text style={labelStyle}>Code sent to {email.trim()}</Text>
202
+ <TextInput
203
+ autoComplete="one-time-code"
204
+ autoFocus
205
+ editable={!busy}
206
+ inputMode="numeric"
207
+ keyboardType="number-pad"
208
+ maxLength={CODE_LENGTH}
209
+ onChangeText={changeCode}
210
+ style={[inputStyle, styles.codeInput]}
211
+ testID="sign-in-code"
212
+ textContentType="oneTimeCode"
213
+ value={code}
214
+ />
215
+ <View style={styles.links}>
216
+ <AuthLink
217
+ disabled={busy || resendIn > 0}
218
+ onPress={sendCode}
219
+ testID="sign-in-resend"
220
+ title={resendIn > 0 ? `Resend in ${resendIn}s` : 'Resend code'}
221
+ />
222
+ <AuthLink
223
+ disabled={busy}
224
+ onPress={changeEmail}
225
+ testID="sign-in-change-email"
226
+ title="Change email"
227
+ />
228
+ </View>
229
+ </>
230
+ );
231
+ }
232
+
233
+ return (
234
+ <KeyboardAvoidingView
235
+ // Android apps are edge-to-edge, so the window no longer resizes for the keyboard; padding
236
+ // by the keyboard's overlap keeps the form above it on both platforms.
237
+ behavior="padding"
238
+ style={{ backgroundColor: tokens.colorGroupedBackground, flex: 1 }}
239
+ >
240
+ <ScrollView contentContainerStyle={styles.screen} keyboardShouldPersistTaps="handled">
241
+ <Text accessibilityRole="header" style={[styles.appName, { color: tokens.colorLabel }]}>
242
+ {appName}
243
+ </Text>
244
+ {form}
245
+ {error ? (
246
+ <Text
247
+ accessibilityRole="alert"
248
+ style={[styles.error, { color: tokens.colorError }]}
249
+ testID="sign-in-error"
250
+ >
251
+ {error}
252
+ </Text>
253
+ ) : null}
254
+ {settings.emailCode ? (
255
+ <AuthLink
256
+ disabled={busy}
257
+ onPress={() => run(openSheet)}
258
+ style={styles.centered}
259
+ testID="sign-in-other"
260
+ title="Other ways to sign in"
261
+ />
262
+ ) : null}
263
+ </ScrollView>
264
+ </KeyboardAvoidingView>
265
+ );
266
+ }
267
+
268
+ export default SignInScreen;
@@ -0,0 +1,187 @@
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 React from 'react';
18
+ import { jest } from '@jest/globals';
19
+ import { act, fireEvent, render, screen } from '@testing-library/react-native';
20
+
21
+ import createNetworkError from '../test/createNetworkError.js';
22
+ import SignInScreen from './SignInScreen.js';
23
+
24
+ const NEUTRAL = 'If this email can sign in, a code is on its way.';
25
+
26
+ function createAuth(overrides = {}) {
27
+ return {
28
+ sendCode: jest.fn(async () => ({ status: 'sent' })),
29
+ signInWithCode: jest.fn(async () => ({ status: 'signedIn' })),
30
+ signInWithSheet: jest.fn(async () => ({ status: 'cancelled' })),
31
+ ...overrides,
32
+ };
33
+ }
34
+
35
+ async function renderScreen({ auth = createAuth(), emailCode = true } = {}) {
36
+ await render(
37
+ <SignInScreen appName="Field" auth={auth} settings={{ emailCode, unlock: 'none' }} />
38
+ );
39
+ return auth;
40
+ }
41
+
42
+ async function enterEmail(email = ' ana@example.com ') {
43
+ await fireEvent.changeText(screen.getByTestId('sign-in-email'), email);
44
+ await fireEvent.press(screen.getByTestId('sign-in-continue'));
45
+ }
46
+
47
+ function isDisabled(testID) {
48
+ return screen.getByTestId(testID).props.accessibilityState?.disabled === true;
49
+ }
50
+
51
+ afterEach(() => {
52
+ jest.useRealTimers();
53
+ });
54
+
55
+ test('the screen shows the app name and an email field, with the sheet as the other way in', async () => {
56
+ await renderScreen();
57
+ expect(screen.getByText('Field')).toBeTruthy();
58
+ const email = screen.getByTestId('sign-in-email');
59
+ expect(email.props.textContentType).toBe('emailAddress');
60
+ expect(email.props.autoComplete).toBe('email');
61
+ expect(isDisabled('sign-in-continue')).toBe(true);
62
+ expect(screen.getByText('Other ways to sign in')).toBeTruthy();
63
+ });
64
+
65
+ test('Continue sends a code to the email and asks for it, saying only that it may be on its way', async () => {
66
+ const auth = await renderScreen();
67
+ await enterEmail();
68
+ expect(auth.sendCode).toHaveBeenCalledWith({ email: 'ana@example.com' });
69
+ expect(screen.getByTestId('sign-in-message')).toHaveTextContent(NEUTRAL);
70
+ const code = screen.getByTestId('sign-in-code');
71
+ expect(code.props.textContentType).toBe('oneTimeCode');
72
+ expect(code.props.autoComplete).toBe('one-time-code');
73
+ expect(code.props.maxLength).toBe(6);
74
+ });
75
+
76
+ test('an address that can not sign in gets the same message, and no hint it is unknown', async () => {
77
+ // The server answers every address alike; the screen has nothing else to say.
78
+ const auth = await renderScreen();
79
+ await enterEmail('stranger@example.com');
80
+ expect(auth.sendCode).toHaveBeenCalledWith({ email: 'stranger@example.com' });
81
+ expect(screen.getByTestId('sign-in-message')).toHaveTextContent(NEUTRAL);
82
+ expect(screen.queryByTestId('sign-in-error')).toBeNull();
83
+ });
84
+
85
+ test('the sixth digit of the code signs in', async () => {
86
+ const auth = await renderScreen();
87
+ await enterEmail();
88
+ await fireEvent.changeText(screen.getByTestId('sign-in-code'), '12345');
89
+ expect(auth.signInWithCode).not.toHaveBeenCalled();
90
+ await fireEvent.changeText(screen.getByTestId('sign-in-code'), '123 456');
91
+ expect(auth.signInWithCode).toHaveBeenCalledWith({ email: 'ana@example.com', otp: '123456' });
92
+ });
93
+
94
+ test('a wrong code says so and clears the field', async () => {
95
+ const auth = await renderScreen({
96
+ auth: createAuth({ signInWithCode: jest.fn(async () => ({ status: 'invalid' })) }),
97
+ });
98
+ await enterEmail();
99
+ await fireEvent.changeText(screen.getByTestId('sign-in-code'), '000000');
100
+ expect(auth.signInWithCode).toHaveBeenCalledTimes(1);
101
+ expect(screen.getByTestId('sign-in-error')).toHaveTextContent(
102
+ 'That code is not right, or it has expired.'
103
+ );
104
+ expect(screen.getByTestId('sign-in-code').props.value).toBe('');
105
+ });
106
+
107
+ test('Resend waits 30 seconds, then sends another code', async () => {
108
+ jest.useFakeTimers();
109
+ const auth = await renderScreen();
110
+ await enterEmail();
111
+ expect(isDisabled('sign-in-resend')).toBe(true);
112
+ expect(screen.getByText('Resend in 30s')).toBeTruthy();
113
+ for (let second = 0; second < 29; second += 1) {
114
+ await act(async () => jest.advanceTimersByTime(1000));
115
+ }
116
+ expect(isDisabled('sign-in-resend')).toBe(true);
117
+ expect(screen.getByText('Resend in 1s')).toBeTruthy();
118
+ await act(async () => jest.advanceTimersByTime(1000));
119
+ expect(isDisabled('sign-in-resend')).toBe(false);
120
+ await fireEvent.press(screen.getByTestId('sign-in-resend'));
121
+ expect(auth.sendCode).toHaveBeenCalledTimes(2);
122
+ expect(isDisabled('sign-in-resend')).toBe(true);
123
+ });
124
+
125
+ test('Change email goes back to the email field', async () => {
126
+ await renderScreen();
127
+ await enterEmail();
128
+ await fireEvent.press(screen.getByTestId('sign-in-change-email'));
129
+ expect(screen.getByTestId('sign-in-email').props.value).toBe(' ana@example.com ');
130
+ expect(screen.queryByTestId('sign-in-code')).toBeNull();
131
+ });
132
+
133
+ test('a person with a second factor finishes in the sheet', async () => {
134
+ const auth = await renderScreen({
135
+ auth: createAuth({ signInWithCode: jest.fn(async () => ({ status: 'twoFactor' })) }),
136
+ });
137
+ await enterEmail();
138
+ await fireEvent.changeText(screen.getByTestId('sign-in-code'), '123456');
139
+ expect(auth.signInWithSheet).toHaveBeenCalledTimes(1);
140
+ });
141
+
142
+ test('Other ways to sign in opens the sheet, and says so when it did not finish', async () => {
143
+ const auth = await renderScreen({
144
+ auth: createAuth({ signInWithSheet: jest.fn(async () => ({ status: 'failed' })) }),
145
+ });
146
+ await fireEvent.press(screen.getByTestId('sign-in-other'));
147
+ expect(auth.signInWithSheet).toHaveBeenCalledTimes(1);
148
+ expect(screen.getByTestId('sign-in-error')).toHaveTextContent(
149
+ 'Sign-in did not finish. Try again.'
150
+ );
151
+ });
152
+
153
+ test('an app without email codes opens the sheet at once, with a button to open it again', async () => {
154
+ const auth = await renderScreen({ emailCode: false });
155
+ expect(auth.signInWithSheet).toHaveBeenCalledTimes(1);
156
+ expect(screen.queryByTestId('sign-in-email')).toBeNull();
157
+ await fireEvent.press(screen.getByTestId('sign-in-sheet'));
158
+ expect(auth.signInWithSheet).toHaveBeenCalledTimes(2);
159
+ });
160
+
161
+ test('a server without the email code route sends the person to the sheet', async () => {
162
+ const auth = await renderScreen({
163
+ auth: createAuth({ sendCode: jest.fn(async () => ({ status: 'unavailable' })) }),
164
+ });
165
+ await enterEmail();
166
+ expect(auth.signInWithSheet).toHaveBeenCalledTimes(1);
167
+ });
168
+
169
+ test('too many codes asks for the newest code already sent', async () => {
170
+ const sendCode = jest.fn().mockResolvedValueOnce({ status: 'rateLimited' });
171
+ await renderScreen({ auth: createAuth({ sendCode }) });
172
+ await enterEmail();
173
+ expect(screen.getByTestId('sign-in-code')).toBeTruthy();
174
+ expect(screen.getByTestId('sign-in-message')).toHaveTextContent(
175
+ 'Too many codes were sent to this email. Use the newest code in your email, or try again later.'
176
+ );
177
+ expect(screen.queryByTestId('sign-in-error')).toBeNull();
178
+ });
179
+
180
+ test('no connection says so', async () => {
181
+ const sendCode = jest.fn().mockRejectedValueOnce(createNetworkError());
182
+ await renderScreen({ auth: createAuth({ sendCode }) });
183
+ await enterEmail();
184
+ expect(screen.getByTestId('sign-in-error')).toHaveTextContent(
185
+ "Could not reach the app's server. Check your connection and try again."
186
+ );
187
+ });