anchordb-lens-link 1.3.0 → 1.3.2

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 (39) hide show
  1. package/README.md +48 -1
  2. package/android/build.gradle +5 -3
  3. package/android/src/main/AndroidManifest.xml +28 -0
  4. package/android/src/main/java/dev/anchordb/lenslink/AnchorLensLinkModule.kt +187 -5
  5. package/android/src/main/java/dev/anchordb/lenslink/LensLinkKeepAliveService.kt +89 -0
  6. package/android/src/main/res/drawable/anchor_lens_link_notification.xml +10 -0
  7. package/dist/cjs/app.d.ts +17 -25
  8. package/dist/cjs/app.d.ts.map +1 -1
  9. package/dist/cjs/app.js +45 -14
  10. package/dist/cjs/app.js.map +1 -1
  11. package/dist/cjs/index.d.ts +8 -3
  12. package/dist/cjs/index.d.ts.map +1 -1
  13. package/dist/cjs/index.js +14 -3
  14. package/dist/cjs/index.js.map +1 -1
  15. package/dist/cjs/keep-alive.d.ts +40 -0
  16. package/dist/cjs/keep-alive.d.ts.map +1 -0
  17. package/dist/cjs/keep-alive.js +53 -0
  18. package/dist/cjs/keep-alive.js.map +1 -0
  19. package/dist/cjs/native.d.ts +57 -0
  20. package/dist/cjs/native.d.ts.map +1 -0
  21. package/dist/cjs/native.js +23 -0
  22. package/dist/cjs/native.js.map +1 -0
  23. package/dist/esm/app.d.ts +17 -25
  24. package/dist/esm/app.d.ts.map +1 -1
  25. package/dist/esm/app.js +44 -13
  26. package/dist/esm/app.js.map +1 -1
  27. package/dist/esm/index.d.ts +8 -3
  28. package/dist/esm/index.d.ts.map +1 -1
  29. package/dist/esm/index.js +5 -2
  30. package/dist/esm/index.js.map +1 -1
  31. package/dist/esm/keep-alive.d.ts +40 -0
  32. package/dist/esm/keep-alive.d.ts.map +1 -0
  33. package/dist/esm/keep-alive.js +43 -0
  34. package/dist/esm/keep-alive.js.map +1 -0
  35. package/dist/esm/native.d.ts +57 -0
  36. package/dist/esm/native.d.ts.map +1 -0
  37. package/dist/esm/native.js +19 -0
  38. package/dist/esm/native.js.map +1 -0
  39. package/package.json +3 -3
package/README.md CHANGED
@@ -3,6 +3,8 @@
3
3
  **Open a QA build's AnchorDB database in Anchor Lens, on the same Android phone, from a file.** No
4
4
  laptop, no relay, no pairing code.
5
5
 
6
+ **[Overview](https://www.nisalanadeera.com/projects/anchordb)** · **[Report a bug or get support](https://www.nisalanadeera.com/projects/anchordb#feedback)**
7
+
6
8
  ```bash
7
9
  npm install anchordb-lens-link
8
10
  ```
@@ -41,6 +43,42 @@ Lens opens the app's database live. Edits in either app show up in the other.
41
43
  The file is rewritten on every launch, because the port and the secret change. It works only while the
42
44
  app is running; if Lens says the app closed, open the app and pick the file again.
43
45
 
46
+ ## Staying alive in the background
47
+
48
+ Android stops apps in the background to save power, and some phones — Xiaomi's especially — do it
49
+ within seconds of switching away. So while the link is on, the app runs an Android **foreground
50
+ service** with an ongoing notification (*Anchor Lens Link on*, then *Anchor Lens connected*), which is
51
+ what Android lets an app use to keep running. Anchor Lens does the same while it is connected, so you
52
+ can switch between the two.
53
+
54
+ It starts with the link, so call `enableLensLink` while the app is on screen: Android 12 and later
55
+ refuse to start one from the background. `keepAlive: false` turns it off, and `link.keepAliveError`
56
+ says why it could not start.
57
+
58
+ Two settings make it dependable. Put them on a QA screen:
59
+
60
+ ```ts
61
+ import {
62
+ askBatteryUnrestricted,
63
+ askNotificationPermission,
64
+ backgroundStatus,
65
+ openBackgroundSettings,
66
+ } from "anchordb-lens-link";
67
+
68
+ const status = backgroundStatus(); // { notifications, batteryUnrestricted, keepingAlive, manufacturer, vendorSettings }
69
+ await askNotificationPermission(); // Android 13+: lets the notification show
70
+ askBatteryUnrestricted(); // Android's "let this app always run in the background?" dialog
71
+ if (status?.vendorSettings) openBackgroundSettings(); // Xiaomi: Autostart, and Battery saver
72
+ ```
73
+
74
+ On a Xiaomi phone also turn on **Autostart**, set **Battery saver** to **No restrictions**, and lock the
75
+ app in Recents.
76
+
77
+ The package adds `FOREGROUND_SERVICE`, `FOREGROUND_SERVICE_SPECIAL_USE`, `POST_NOTIFICATIONS` and
78
+ `REQUEST_IGNORE_BATTERY_OPTIMIZATIONS` to the app's manifest, with a `specialUse` foreground service.
79
+ That suits a QA build; a Play Store build that shipped the package would have to justify them — one
80
+ more reason to keep it out of production.
81
+
44
82
  ## Keep it out of production
45
83
 
46
84
  A link exposes the database to Anchor Lens on the phone, so:
@@ -69,9 +107,10 @@ enableLensLink(db, options?): Promise<LensLink>
69
107
  | `allowInProduction` | Required in a release build |
70
108
  | `readOnly` | Refuse every write from Lens |
71
109
  | `anchorVersion`, `platform` | Reported to Lens |
110
+ | `keepAlive` | Keep the app running in the background while the link is on. Default `true` |
72
111
  | `onConnectionsChange(count)` | Lens connections opening and closing |
73
112
 
74
- `LensLink` has `port`, `fileName`, `filePath`, `connections`, `agent` and `close()`. Calling
113
+ `LensLink` has `port`, `fileName`, `filePath`, `connections`, `keepAliveError`, `agent` and `close()`. Calling
75
114
  `enableLensLink` again for the same database replaces the running link, so it is safe in an effect and
76
115
  through Fast Refresh.
77
116
 
@@ -85,3 +124,11 @@ For a Lens client: `readLensLink(fileText)` returns `{ app, database, port, secr
85
124
  React Native app needs `expo-modules-core` installed). iOS is not supported yet.
86
125
  - `anchordb` 1.3 or later, whose inspector accepts a shared secret.
87
126
  - Anchor Lens with **Open app from file**.
127
+
128
+ ## Bugs, support and feedback
129
+
130
+ Report a bug, ask for help or suggest a feature on the [AnchorDB project page](https://www.nisalanadeera.com/projects/anchordb#feedback) —
131
+ choose `anchordb-lens-link` as the package, and the reply comes by email.
132
+
133
+ Include the version (`npm ls anchordb`), where it runs, the smallest snippet that reproduces it, and
134
+ the full error.
@@ -4,13 +4,13 @@ plugins {
4
4
  }
5
5
 
6
6
  group = 'dev.anchordb'
7
- version = '1.3.0'
7
+ version = '1.3.2'
8
8
 
9
9
  android {
10
10
  namespace "dev.anchordb.lenslink"
11
11
  defaultConfig {
12
- versionCode 1
13
- versionName '1.3.0'
12
+ versionCode 3
13
+ versionName '1.3.2'
14
14
  }
15
15
  }
16
16
 
@@ -18,4 +18,6 @@ dependencies {
18
18
  // The WebSocket server on 127.0.0.1. Anchor Lens connects with React Native's built-in WebSocket, so
19
19
  // this is the only native networking code on either side.
20
20
  implementation "org.java-websocket:Java-WebSocket:1.6.0"
21
+ // NotificationCompat and ServiceCompat, for the foreground service that keeps the app running.
22
+ implementation "androidx.core:core-ktx:1.17.0"
21
23
  }
@@ -0,0 +1,28 @@
1
+ <manifest xmlns:android="http://schemas.android.com/apk/res/android">
2
+
3
+ <!-- Keep the app running while Anchor Lens Link is on: a foreground service with a notification. -->
4
+ <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
5
+ <uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
6
+ <!-- Android 13 and later: lets that notification show. -->
7
+ <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
8
+ <!-- The system "let this app always run in the background?" dialog. -->
9
+ <uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
10
+
11
+ <!-- Phone makers' own background-running screens, so the app can check they exist before opening one. -->
12
+ <queries>
13
+ <package android:name="com.miui.securitycenter" />
14
+ <package android:name="com.coloros.safecenter" />
15
+ <package android:name="com.vivo.permissionmanager" />
16
+ </queries>
17
+
18
+ <application>
19
+ <service
20
+ android:name="dev.anchordb.lenslink.LensLinkKeepAliveService"
21
+ android:exported="false"
22
+ android:foregroundServiceType="specialUse">
23
+ <property
24
+ android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
25
+ android:value="Keeps a development database inspector connection (Anchor Lens Link) open while the app is in the background." />
26
+ </service>
27
+ </application>
28
+ </manifest>
@@ -1,6 +1,19 @@
1
1
  package dev.anchordb.lenslink
2
2
 
3
+ import android.Manifest
4
+ import android.annotation.SuppressLint
5
+ import android.app.NotificationManager
6
+ import android.content.ComponentName
7
+ import android.content.Context
8
+ import android.content.Intent
9
+ import android.content.pm.PackageManager
10
+ import android.net.Uri
11
+ import android.os.Build
3
12
  import android.os.Bundle
13
+ import android.os.PowerManager
14
+ import android.provider.Settings
15
+ import androidx.core.content.ContextCompat
16
+ import expo.modules.interfaces.permissions.Permissions
4
17
  import expo.modules.kotlin.Promise
5
18
  import expo.modules.kotlin.exception.CodedException
6
19
  import expo.modules.kotlin.exception.Exceptions
@@ -20,11 +33,12 @@ import java.util.concurrent.atomic.AtomicBoolean
20
33
  import java.util.concurrent.atomic.AtomicInteger
21
34
 
22
35
  /**
23
- * The native half of Anchor Lens Link: a WebSocket server on this phone's loopback address, and the link
24
- * file in Android/media/<package>/anchor-lens/.
36
+ * The native half of Anchor Lens Link: a WebSocket server on this phone's loopback address, the link file
37
+ * in Android/media/<package>/anchor-lens/, and what keeps the app running in the background.
25
38
  *
26
- * Only transport and files live here. Authentication, the inspector protocol and the database are all
27
- * JavaScript, in anchordb itself — this module never sees a secret it checks, or a document.
39
+ * Only transport, files and Android settings live here. Authentication, the inspector protocol and the
40
+ * database are all JavaScript, in anchordb itself — this module never sees a secret it checks, or a
41
+ * document.
28
42
  */
29
43
  class AnchorLensLinkModule : Module() {
30
44
  private var server: LinkServer? = null
@@ -68,8 +82,41 @@ class AnchorLensLinkModule : Module() {
68
82
  linkFile(name).delete()
69
83
  }
70
84
 
85
+ AsyncFunction("startKeepAlive") { title: String, text: String ->
86
+ startKeepAliveNow(title, text)
87
+ }
88
+
89
+ Function("updateKeepAlive") { title: String, text: String ->
90
+ updateKeepAliveNow(title, text)
91
+ }
92
+
93
+ Function("stopKeepAlive") {
94
+ stopKeepAliveNow()
95
+ }
96
+
97
+ Function("backgroundStatus") {
98
+ backgroundStatusNow()
99
+ }
100
+
101
+ AsyncFunction("askNotificationPermission") { promise: Promise ->
102
+ askNotificationPermissionNow(promise)
103
+ }
104
+
105
+ Function("askBatteryUnrestricted") {
106
+ askBatteryUnrestrictedNow()
107
+ }
108
+
109
+ Function("openBackgroundSettings") {
110
+ openBackgroundSettingsNow()
111
+ }
112
+
113
+ Function("openNotificationSettings") {
114
+ openNotificationSettingsNow()
115
+ }
116
+
71
117
  OnDestroy {
72
118
  stopServerNow()
119
+ stopKeepAliveNow()
73
120
  }
74
121
  }
75
122
 
@@ -86,7 +133,7 @@ class AnchorLensLinkModule : Module() {
86
133
  server?.closeConnection(id)
87
134
  }
88
135
 
89
- private fun context() = appContext.reactContext ?: throw Exceptions.ReactContextLost()
136
+ private fun context(): Context = appContext.reactContext ?: throw Exceptions.ReactContextLost()
90
137
 
91
138
  @Suppress("DEPRECATION")
92
139
  private fun linkFile(name: String): File {
@@ -97,8 +144,137 @@ class AnchorLensLinkModule : Module() {
97
144
  return File(File(media, "anchor-lens"), name)
98
145
  }
99
146
 
147
+ // region Keeping the app running
148
+
149
+ private fun startKeepAliveNow(title: String, text: String) {
150
+ val context = context()
151
+ val intent =
152
+ Intent(context, LensLinkKeepAliveService::class.java)
153
+ .putExtra(LensLinkKeepAliveService.EXTRA_TITLE, title)
154
+ .putExtra(LensLinkKeepAliveService.EXTRA_TEXT, text)
155
+ try {
156
+ ContextCompat.startForegroundService(context, intent)
157
+ } catch (err: IllegalStateException) {
158
+ // Android 12 and later refuse to start one while the app is in the background.
159
+ throw KeepAliveNotAllowed(err)
160
+ }
161
+ }
162
+
163
+ private fun updateKeepAliveNow(title: String, text: String) {
164
+ if (!LensLinkKeepAliveService.running) return
165
+ val context = context()
166
+ try {
167
+ context
168
+ .getSystemService(NotificationManager::class.java)
169
+ ?.notify(LensLinkKeepAliveService.NOTIFICATION_ID, LensLinkKeepAliveService.notification(context, title, text))
170
+ } catch (_: SecurityException) {
171
+ // Without notification permission the service keeps running; only its text is not shown.
172
+ }
173
+ }
174
+
175
+ private fun stopKeepAliveNow() {
176
+ val context = appContext.reactContext ?: return
177
+ context.stopService(Intent(context, LensLinkKeepAliveService::class.java))
178
+ }
179
+
180
+ private fun backgroundStatusNow(): Map<String, Any> {
181
+ val context = context()
182
+ val notifications =
183
+ Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
184
+ ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) ==
185
+ PackageManager.PERMISSION_GRANTED
186
+ val power = context.getSystemService(PowerManager::class.java)
187
+ return mapOf(
188
+ "notifications" to (if (notifications) "granted" else "denied"),
189
+ "batteryUnrestricted" to (power?.isIgnoringBatteryOptimizations(context.packageName) ?: true),
190
+ "keepingAlive" to LensLinkKeepAliveService.running,
191
+ "manufacturer" to Build.MANUFACTURER,
192
+ "vendorSettings" to (vendorScreen(context) != null),
193
+ )
194
+ }
195
+
196
+ private fun askNotificationPermissionNow(promise: Promise) {
197
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
198
+ // Before Android 13 notifications need no permission.
199
+ promise.resolve(
200
+ Bundle().apply {
201
+ putBoolean("granted", true)
202
+ putBoolean("canAskAgain", true)
203
+ },
204
+ )
205
+ return
206
+ }
207
+ Permissions.askForPermissionsWithPermissionsManager(
208
+ appContext.permissions,
209
+ promise,
210
+ Manifest.permission.POST_NOTIFICATIONS,
211
+ )
212
+ }
213
+
214
+ @SuppressLint("BatteryLife")
215
+ private fun askBatteryUnrestrictedNow(): Boolean {
216
+ val context = context()
217
+ val power = context.getSystemService(PowerManager::class.java)
218
+ if (power?.isIgnoringBatteryOptimizations(context.packageName) == true) return true
219
+ return openScreen(Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS, Uri.parse("package:${context.packageName}"))) ||
220
+ openScreen(Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS))
221
+ }
222
+
223
+ private fun openBackgroundSettingsNow(): String {
224
+ val context = context()
225
+ val vendor = vendorScreen(context)
226
+ return when {
227
+ vendor != null && openScreen(vendor) -> "vendor"
228
+ openScreen(Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, Uri.parse("package:${context.packageName}"))) -> "app"
229
+ else -> "none"
230
+ }
231
+ }
232
+
233
+ private fun openNotificationSettingsNow(): Boolean {
234
+ val context = context()
235
+ val intent =
236
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
237
+ Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName)
238
+ } else {
239
+ Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, Uri.parse("package:${context.packageName}"))
240
+ }
241
+ return openScreen(intent)
242
+ }
243
+
244
+ private fun openScreen(intent: Intent): Boolean {
245
+ val activity = appContext.currentActivity
246
+ return try {
247
+ if (activity != null) {
248
+ activity.startActivity(intent)
249
+ } else {
250
+ context().startActivity(intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
251
+ }
252
+ true
253
+ } catch (_: Exception) {
254
+ false
255
+ }
256
+ }
257
+
258
+ private fun vendorScreen(context: Context): Intent? {
259
+ val screens = VENDOR_SCREENS[Build.MANUFACTURER.lowercase()] ?: return null
260
+ return screens
261
+ .map { (pkg, activity) -> Intent().setComponent(ComponentName(pkg, activity)) }
262
+ .firstOrNull { it.resolveActivity(context.packageManager) != null }
263
+ }
264
+
265
+ // endregion
266
+
100
267
  companion object {
101
268
  private val FILE_NAME = Regex("^[A-Za-z0-9._-]{1,120}\\.anchorlens$")
269
+
270
+ // Makers whose phones stop background apps beyond what Android does, with the screen that lets an app
271
+ // keep running. Redmi and POCO phones report "Xiaomi".
272
+ private val VENDOR_SCREENS =
273
+ mapOf(
274
+ "xiaomi" to listOf("com.miui.securitycenter" to "com.miui.permcenter.autostart.AutoStartManagementActivity"),
275
+ "oppo" to listOf("com.coloros.safecenter" to "com.coloros.safecenter.permission.startup.StartupAppListActivity"),
276
+ "vivo" to listOf("com.vivo.permissionmanager" to "com.vivo.permissionmanager.activity.BgStartUpManagerActivity"),
277
+ )
102
278
  }
103
279
  }
104
280
 
@@ -108,6 +284,12 @@ private class InvalidLinkFileName(name: String) :
108
284
  private class NoMediaDirectory :
109
285
  CodedException("This device has no shared media storage to write the Anchor Lens link file to.")
110
286
 
287
+ private class KeepAliveNotAllowed(cause: Throwable) :
288
+ CodedException(
289
+ "Android did not let the app keep running in the background, because it was not on screen. Turn the link on while the app is open.",
290
+ cause,
291
+ )
292
+
111
293
  /** One launch's server. A stopped WebSocketServer cannot be started again, so each start makes a new one. */
112
294
  private class LinkServer(
113
295
  private val module: AnchorLensLinkModule,
@@ -0,0 +1,89 @@
1
+ package dev.anchordb.lenslink
2
+
3
+ import android.app.Notification
4
+ import android.app.NotificationChannel
5
+ import android.app.NotificationManager
6
+ import android.app.PendingIntent
7
+ import android.app.Service
8
+ import android.content.Context
9
+ import android.content.Intent
10
+ import android.content.pm.ServiceInfo
11
+ import android.os.Build
12
+ import android.os.IBinder
13
+ import androidx.core.app.NotificationCompat
14
+ import androidx.core.app.ServiceCompat
15
+
16
+ /**
17
+ * Keeps the app's process running while Anchor Lens Link is on — or, in Anchor Lens, while it is connected
18
+ * to an app.
19
+ *
20
+ * Android freezes and then stops apps in the background, and some phones (Xiaomi's especially) do it
21
+ * within seconds of switching away. A foreground service, with its ongoing notification, is what Android
22
+ * lets an app use to keep running, so the link survives switching between the two apps. Found on a phone.
23
+ */
24
+ class LensLinkKeepAliveService : Service() {
25
+ override fun onBind(intent: Intent?): IBinder? = null
26
+
27
+ override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
28
+ val title = intent?.getStringExtra(EXTRA_TITLE) ?: "Anchor Lens Link"
29
+ val text = intent?.getStringExtra(EXTRA_TEXT) ?: ""
30
+ val type =
31
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
32
+ ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE
33
+ } else {
34
+ 0
35
+ }
36
+ ServiceCompat.startForeground(this, NOTIFICATION_ID, notification(this, title, text), type)
37
+ running = true
38
+ // Not restarted after being stopped: the JavaScript that answers Lens would not be running anyway.
39
+ return START_NOT_STICKY
40
+ }
41
+
42
+ override fun onDestroy() {
43
+ running = false
44
+ ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
45
+ super.onDestroy()
46
+ }
47
+
48
+ companion object {
49
+ const val NOTIFICATION_ID = 0x4c4c
50
+ const val CHANNEL_ID = "anchor-lens-link"
51
+ const val EXTRA_TITLE = "title"
52
+ const val EXTRA_TEXT = "text"
53
+
54
+ @Volatile
55
+ var running = false
56
+
57
+ fun notification(context: Context, title: String, text: String): Notification {
58
+ ensureChannel(context)
59
+ val launch = context.packageManager.getLaunchIntentForPackage(context.packageName)
60
+ val open =
61
+ launch?.let {
62
+ PendingIntent.getActivity(context, 0, it, PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT)
63
+ }
64
+ val builder =
65
+ NotificationCompat.Builder(context, CHANNEL_ID)
66
+ .setSmallIcon(R.drawable.anchor_lens_link_notification)
67
+ .setContentTitle(title)
68
+ .setContentText(text)
69
+ .setStyle(NotificationCompat.BigTextStyle().bigText(text))
70
+ .setOngoing(true)
71
+ .setOnlyAlertOnce(true)
72
+ .setPriority(NotificationCompat.PRIORITY_LOW)
73
+ .setCategory(NotificationCompat.CATEGORY_SERVICE)
74
+ .setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE)
75
+ if (open != null) builder.setContentIntent(open)
76
+ return builder.build()
77
+ }
78
+
79
+ private fun ensureChannel(context: Context) {
80
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
81
+ val manager = context.getSystemService(NotificationManager::class.java) ?: return
82
+ if (manager.getNotificationChannel(CHANNEL_ID) != null) return
83
+ val channel = NotificationChannel(CHANNEL_ID, "Anchor Lens Link", NotificationManager.IMPORTANCE_LOW)
84
+ channel.description = "Shown while this app keeps running for Anchor Lens."
85
+ channel.setShowBadge(false)
86
+ manager.createNotificationChannel(channel)
87
+ }
88
+ }
89
+ }
@@ -0,0 +1,10 @@
1
+ <vector xmlns:android="http://schemas.android.com/apk/res/android"
2
+ android:width="24dp"
3
+ android:height="24dp"
4
+ android:viewportWidth="24"
5
+ android:viewportHeight="24">
6
+ <!-- An eye: something is looking at this app's data. -->
7
+ <path
8
+ android:fillColor="#FFFFFFFF"
9
+ android:pathData="M12,4.5C7,4.5 2.73,7.61 1,12c1.73,4.39 6,7.5 11,7.5s9.27,-3.11 11,-7.5c-1.73,-4.39 -6,-7.5 -11,-7.5zM12,17c-2.76,0 -5,-2.24 -5,-5s2.24,-5 5,-5 5,2.24 5,5 -2.24,5 -5,5zM12,9c-1.66,0 -3,1.34 -3,3s1.34,3 3,3 3,-1.34 3,-3 -1.34,-3 -3,-3z" />
10
+ </vector>
package/dist/cjs/app.d.ts CHANGED
@@ -1,31 +1,14 @@
1
1
  import { InspectorAgent, type AnchorDB } from "anchordb";
2
+ import { type LensLinkNative } from "./native.js";
2
3
  /**
3
4
  * The app half of Anchor Lens Link.
4
5
  *
5
6
  * `enableLensLink(db)` starts a WebSocket server on 127.0.0.1, puts an `InspectorAgent` on the database
6
- * behind it, and writes the link file Lens opens. Every connection is an ordinary inspector session —
7
- * the same protocol, capability checks and Model API writes as through the relay — authenticated with a
8
- * secret made for this launch, which exists only inside the sealed file.
7
+ * behind it, writes the link file Lens opens, and keeps the app running in the background while the link
8
+ * is on. Every connection is an ordinary inspector session — the same protocol, capability checks and
9
+ * Model API writes as through the relay — authenticated with a secret made for this launch, which exists
10
+ * only inside the sealed file.
9
11
  */
10
- /** An event from the native module. */
11
- export interface LensLinkNativeEvent {
12
- id: number;
13
- data?: string;
14
- code?: number;
15
- }
16
- /** The native module's surface. Exported so a test, or another runtime, can supply its own. */
17
- export interface LensLinkNative {
18
- startServer(): Promise<number>;
19
- stopServer(): Promise<void>;
20
- send(id: number, data: string): boolean;
21
- closeConnection(id: number): void;
22
- packageName(): string;
23
- writeLinkFile(name: string, contents: string): string;
24
- deleteLinkFile(name: string): boolean;
25
- addListener(event: "onOpen" | "onMessage" | "onClose", listener: (event: LensLinkNativeEvent) => void): {
26
- remove(): void;
27
- };
28
- }
29
12
  export interface LensLinkOptions {
30
13
  /**
31
14
  * Required in a release build — which includes a QA APK. Lens Link exposes the database to Anchor
@@ -37,6 +20,12 @@ export interface LensLinkOptions {
37
20
  /** Reported to Lens. */
38
21
  anchorVersion?: string;
39
22
  platform?: string;
23
+ /**
24
+ * Keep the app running in the background while the link is on, with an ongoing notification — an
25
+ * Android foreground service. On by default: without it Android stops the app soon after you switch to
26
+ * Lens, and some phones within seconds. `backgroundStatus()` reports the settings that help it hold.
27
+ */
28
+ keepAlive?: boolean;
40
29
  /** Called whenever a Lens connection opens or closes, with how many are open. */
41
30
  onConnectionsChange?: (count: number) => void;
42
31
  /** The native module. Defaults to the one this package installs. */
@@ -52,14 +41,17 @@ export interface LensLink {
52
41
  readonly filePath: string;
53
42
  /** Lens connections open right now. */
54
43
  readonly connections: number;
55
- /** Stop listening, close every Lens connection, and delete the link file. */
44
+ /** Why the app could not be kept running in the background; null when it is, or when keepAlive is off. */
45
+ readonly keepAliveError: string | null;
46
+ /** Stop listening, close every Lens connection, delete the link file, and stop keeping the app running. */
56
47
  close(): Promise<void>;
57
48
  }
58
49
  /**
59
50
  * Let Anchor Lens on this phone open `db`. For development and QA builds.
60
51
  *
61
- * Calling it again for the same database replaces the running link, so it is safe in an effect or
62
- * after a reload. Resolves once the link file is written.
52
+ * Call it while the app is on screen — at startup, say — since Android only lets a foreground app start
53
+ * keeping itself running. Calling it again for the same database replaces the running link, so it is safe
54
+ * in an effect or after a reload. Resolves once the link file is written.
63
55
  */
64
56
  export declare function enableLensLink(db: AnchorDB, options?: LensLinkOptions): Promise<LensLink>;
65
57
  //# sourceMappingURL=app.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"app.d.ts","sourceRoot":"","sources":["../../src/app.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,cAAc,EAGd,KAAK,QAAQ,EAEd,MAAM,UAAU,CAAC;AAGlB;;;;;;;GAOG;AAEH,uCAAuC;AACvC,MAAM,WAAW,mBAAmB;IAClC,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,IAAI,CAAC,EAAE,MAAM,CAAC;CACf;AAED,+FAA+F;AAC/F,MAAM,WAAW,cAAc;IAC7B,WAAW,IAAI,OAAO,CAAC,MAAM,CAAC,CAAC;IAC/B,UAAU,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;IAC5B,IAAI,CAAC,EAAE,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC;IACxC,eAAe,CAAC,EAAE,EAAE,MAAM,GAAG,IAAI,CAAC;IAClC,WAAW,IAAI,MAAM,CAAC;IACtB,aAAa,CAAC,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,MAAM,CAAC;IACtD,cAAc,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC;IACtC,WAAW,CACT,KAAK,EAAE,QAAQ,GAAG,WAAW,GAAG,SAAS,EACzC,QAAQ,EAAE,CAAC,KAAK,EAAE,mBAAmB,KAAK,IAAI,GAC7C;QAAE,MAAM,IAAI,IAAI,CAAA;KAAE,CAAC;CACvB;AAED,MAAM,WAAW,eAAe;IAC9B;;;OAGG;IACH,iBAAiB,CAAC,EAAE,OAAO,CAAC;IAC5B,oCAAoC;IACpC,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,wBAAwB;IACxB,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,iFAAiF;IACjF,mBAAmB,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;IAC9C,oEAAoE;IACpE,MAAM,CAAC,EAAE,cAAc,CAAC;CACzB;AAED,MAAM,WAAW,QAAQ;IACvB,QAAQ,CAAC,KAAK,EAAE,cAAc,CAAC;IAC/B,6BAA6B;IAC7B,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,qDAAqD;IACrD,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,gHAAgH;IAChH,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,uCAAuC;IACvC,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;IAC7B,6EAA6E;IAC7E,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;CACxB;AAOD;;;;;GAKG;AACH,wBAAsB,cAAc,CAAC,EAAE,EAAE,QAAQ,EAAE,OAAO,GAAE,eAAoB,GAAG,OAAO,CAAC,QAAQ,CAAC,CAOnG"}
1
+ {"version":3,"file":"app.d.ts","sourceRoot":"","sources":["../../src/app.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,cAAc,EAGd,KAAK,QAAQ,EAEd,MAAM,UAAU,CAAC;AAElB,OAAO,EAAgB,KAAK,cAAc,EAAE,MAAM,aAAa,CAAC;AAEhE;;;;;;;;GAQG;AAEH,MAAM,WAAW,eAAe;IAC9B;;;OAGG;IACH,iBAAiB,CAAC,EAAE,OAAO,CAAC;IAC5B,oCAAoC;IACpC,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,wBAAwB;IACxB,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB;;;;OAIG;IACH,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,iFAAiF;IACjF,mBAAmB,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;IAC9C,oEAAoE;IACpE,MAAM,CAAC,EAAE,cAAc,CAAC;CACzB;AAED,MAAM,WAAW,QAAQ;IACvB,QAAQ,CAAC,KAAK,EAAE,cAAc,CAAC;IAC/B,6BAA6B;IAC7B,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,qDAAqD;IACrD,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,gHAAgH;IAChH,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,uCAAuC;IACvC,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;IAC7B,0GAA0G;IAC1G,QAAQ,CAAC,cAAc,EAAE,MAAM,GAAG,IAAI,CAAC;IACvC,2GAA2G;IAC3G,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;CACxB;AAOD;;;;;;GAMG;AACH,wBAAsB,cAAc,CAAC,EAAE,EAAE,QAAQ,EAAE,OAAO,GAAE,eAAoB,GAAG,OAAO,CAAC,QAAQ,CAAC,CAOnG"}
package/dist/cjs/app.js CHANGED
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.enableLensLink = enableLensLink;
4
4
  const anchordb_1 = require("anchordb");
5
5
  const link_file_ts_1 = require("./link-file.js");
6
+ const native_ts_1 = require("./native.js");
6
7
  // On globalThis, not in module scope: Fast Refresh re-runs this module, and a registry that reset with it
7
8
  // would leave the previous agent answering on the previous server's events.
8
9
  const registry = (globalThis.__anchordbLensLinks ??=
@@ -10,12 +11,13 @@ const registry = (globalThis.__anchordbLensLinks ??=
10
11
  /**
11
12
  * Let Anchor Lens on this phone open `db`. For development and QA builds.
12
13
  *
13
- * Calling it again for the same database replaces the running link, so it is safe in an effect or
14
- * after a reload. Resolves once the link file is written.
14
+ * Call it while the app is on screen — at startup, say — since Android only lets a foreground app start
15
+ * keeping itself running. Calling it again for the same database replaces the running link, so it is safe
16
+ * in an effect or after a reload. Resolves once the link file is written.
15
17
  */
16
18
  async function enableLensLink(db, options = {}) {
17
19
  (0, anchordb_1.assertInspectorAllowed)(db.name, options.allowInProduction);
18
- const native = options.native ?? nativeModule();
20
+ const native = options.native ?? (0, native_ts_1.nativeModule)();
19
21
  await registry.get(db.name)?.close();
20
22
  const link = await start(db, native, options);
21
23
  registry.set(db.name, link);
@@ -26,6 +28,7 @@ async function start(db, native, options) {
26
28
  // proves the same value.
27
29
  const secret = (0, link_file_ts_1.randomHex)(32).toUpperCase();
28
30
  const platform = options.platform ?? "android";
31
+ const fileName = `${db.name.replace(/[^A-Za-z0-9._-]/g, "_").slice(0, 100)}.anchorlens`;
29
32
  const agent = new anchordb_1.InspectorAgent(db, {
30
33
  auth: { sharedSecret: secret },
31
34
  platform,
@@ -33,7 +36,23 @@ async function start(db, native, options) {
33
36
  ...(options.readOnly ? { readOnly: true } : {}),
34
37
  });
35
38
  const sockets = new Map();
36
- const report = () => options.onConnectionsChange?.(sockets.size);
39
+ let keepingAlive = false;
40
+ let keepAliveError = null;
41
+ /** The notification: whether Lens is connected right now. */
42
+ const notice = () => sockets.size > 0
43
+ ? ["Anchor Lens connected", `Anchor Lens is reading ${db.name}. This app keeps running while you use Lens.`]
44
+ : ["Anchor Lens Link on", `Anchor Lens can open ${db.name} from ${fileName}.`];
45
+ const report = () => {
46
+ if (keepingAlive) {
47
+ try {
48
+ native.updateKeepAlive(...notice());
49
+ }
50
+ catch {
51
+ // The notification's wording is a courtesy; the connection does not depend on it.
52
+ }
53
+ }
54
+ options.onConnectionsChange?.(sockets.size);
55
+ };
37
56
  const subscriptions = [
38
57
  native.addListener("onOpen", ({ id }) => {
39
58
  const socket = new NativeSocket(native, id);
@@ -54,7 +73,6 @@ async function start(db, native, options) {
54
73
  report();
55
74
  }),
56
75
  ];
57
- const fileName = `${db.name.replace(/[^A-Za-z0-9._-]/g, "_").slice(0, 100)}.anchorlens`;
58
76
  let port;
59
77
  let filePath;
60
78
  try {
@@ -78,6 +96,16 @@ async function start(db, native, options) {
78
96
  await agent.close();
79
97
  throw err;
80
98
  }
99
+ if (options.keepAlive !== false) {
100
+ try {
101
+ await native.startKeepAlive(...notice());
102
+ keepingAlive = true;
103
+ }
104
+ catch (err) {
105
+ // The link still works while the app is on screen, so this is reported, not thrown.
106
+ keepAliveError = err.message;
107
+ }
108
+ }
81
109
  let closed = false;
82
110
  const link = {
83
111
  agent,
@@ -87,6 +115,9 @@ async function start(db, native, options) {
87
115
  get connections() {
88
116
  return sockets.size;
89
117
  },
118
+ get keepAliveError() {
119
+ return keepAliveError;
120
+ },
90
121
  close: async () => {
91
122
  if (closed)
92
123
  return;
@@ -100,6 +131,15 @@ async function start(db, native, options) {
100
131
  socket.closed();
101
132
  }
102
133
  sockets.clear();
134
+ if (keepingAlive) {
135
+ keepingAlive = false;
136
+ try {
137
+ native.stopKeepAlive();
138
+ }
139
+ catch {
140
+ // Already stopped, or the app is shutting down.
141
+ }
142
+ }
103
143
  try {
104
144
  native.deleteLinkFile(fileName);
105
145
  }
@@ -154,13 +194,4 @@ class NativeSocket {
154
194
  listener(event);
155
195
  }
156
196
  }
157
- function nativeModule() {
158
- const modules = globalThis.expo?.modules;
159
- const found = modules?.AnchorLensLink;
160
- if (!found) {
161
- throw new Error("Anchor Lens Link needs its native module, and this build does not have it. It runs on Android, in a " +
162
- "development or release build (not Expo Go): install anchordb-lens-link, then rebuild the app.");
163
- }
164
- return found;
165
- }
166
197
  //# sourceMappingURL=app.js.map