craft-native 0.0.396 → 0.0.408

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/README.md CHANGED
@@ -598,6 +598,13 @@ precommitted ID without adopting an unknown recording. The asynchronous Crypto
598
598
  service uses the platform's secure UUID generator; an unsupported native host
599
599
  rejects with `CRYPTO_UNSUPPORTED` without falling back to JavaScript entropy.
600
600
 
601
+ To save battery on a long effort, start with `powerMode: 'saver'`, or `'auto'` to
602
+ save only while iOS Low Power Mode or Android Battery Saver is on or the battery
603
+ is low. Change a running recording with
604
+ `location.setRecordingPowerMode({ expectedId: id, powerMode })` after checking
605
+ `location.supportsRecordingPowerMode()`; recording states report `powerMode` and
606
+ `effectivePowerMode` (see `docs/api/mobile/location.md`).
607
+
601
608
  Pass `{ expectedId: id }` to pause, resume, stop, read and get-state calls. Heart-rate reads also accept
602
609
  `since`. A supplied empty, whitespace or non-string ID rejects with
603
610
  `INVALID_ARGUMENT`. Starting while another session is active rejects with
@@ -323,6 +323,7 @@ class CraftBridge(
323
323
  crypto: true,
324
324
  locationRecordingOwnership: {{ENABLE_GEOLOCATION}} && ${CraftNative.supportsRecordingOwnership()},
325
325
  locationRecordingIntentIdentity: {{ENABLE_GEOLOCATION}} && ${CraftNative.supportsRecordingIntentIdentity()},
326
+ locationRecordingPowerMode: {{ENABLE_GEOLOCATION}} && ${CraftNative.supportsRecordingPowerMode()},
326
327
  backgroundLocation: {{ENABLE_BACKGROUND_LOCATION}},
327
328
  clipboard: false,
328
329
  deviceInfo: true,
@@ -1714,6 +1715,7 @@ class CraftBridge(
1714
1715
  watchPosition: craft.watchPosition,
1715
1716
  clearWatch: craft.clearWatch,
1716
1717
  startRecording: function(options) { return recordingCall('startRecording', options); },
1718
+ setRecordingPowerMode: function(options) { return recordingCall('setRecordingPowerMode', options); },
1717
1719
  pauseRecording: function(options) { return recordingCall('pauseRecording', options); },
1718
1720
  resumeRecording: function(options) { return recordingCall('resumeRecording', options); },
1719
1721
  stopRecording: function(options) { return recordingCall('stopRecording', options); },
@@ -1954,6 +1954,13 @@ object CraftNative {
1954
1954
  return try { nativeRecordingOwnershipVersion() >= 1 } catch (_: UnsatisfiedLinkError) { false }
1955
1955
  }
1956
1956
 
1957
+ /**
1958
+ * `setRecordingPowerMode` and `powerMode: 'auto'`. The Kotlin store serves
1959
+ * any method it is handed, so a linked runtime that passes recorder calls
1960
+ * through (version 1 and up) supports it exactly as it supports ownership.
1961
+ */
1962
+ fun supportsRecordingPowerMode(): Boolean = supportsRecordingOwnership()
1963
+
1957
1964
  fun supportsRecordingIntentIdentity(): Boolean {
1958
1965
  if (!isAvailable) return true
1959
1966
  return try { nativeRecordingOwnershipVersion() >= 2 } catch (_: UnsatisfiedLinkError) { false }
@@ -135,6 +135,7 @@ internal class CraftNativeServices(
135
135
  add("geolocation")
136
136
  if (CraftNative.supportsRecordingOwnership()) add("locationRecordingOwnership")
137
137
  if (CraftNative.supportsRecordingIntentIdentity()) add("locationRecordingIntentIdentity")
138
+ if (CraftNative.supportsRecordingPowerMode()) add("locationRecordingPowerMode")
138
139
  }
139
140
  if (config.optJSONObject("backgroundRefresh")?.optBoolean("enabled") == true || config.optBoolean("enablePushNotifications")) add("background")
140
141
  // A Bluetooth heart-rate strap (CraftHeartRate.kt), the app's own
@@ -513,7 +514,7 @@ internal class CraftNativeServices(
513
514
  watchCallback = null
514
515
  done(success(true))
515
516
  }
516
- "startRecording", "pauseRecording", "resumeRecording", "stopRecording", "getRecordingState", "readRecording", "acknowledgeRecording" -> {
517
+ "startRecording", "setRecordingPowerMode", "pauseRecording", "resumeRecording", "stopRecording", "getRecordingState", "readRecording", "acknowledgeRecording" -> {
517
518
  val invoke = {
518
519
  val answer = JSONObject(store.perform(context, method, (args.optJSONObject(0) ?: JSONObject()).toString()))
519
520
  if (answer.optBoolean("ok")) {
@@ -93,6 +93,9 @@ internal data class CraftNativeTabBarAppearance(
93
93
 
94
94
  internal data class CraftTabIconAppearance(val symbol: String, val tint: Int?, val pulse: Boolean)
95
95
 
96
+ /** The app's faces for the chrome (`fontFamily`, `headingFontFamily`); null keeps the system face. */
97
+ internal data class CraftNativeChromeFonts(val body: Typeface? = null, val heading: Typeface? = null)
98
+
96
99
  /** The app's paths (`nativeScreens`), its tabs and its site. */
97
100
  internal class CraftNativeRoutes(config: JSONObject) {
98
101
  private data class Route(val segments: List<String>, val screen: String)
@@ -137,6 +140,10 @@ internal class CraftNativeRoutes(config: JSONObject) {
137
140
  }
138
141
  ?: CraftNativeTabBarAppearance()
139
142
 
143
+ /** CSS stacks for the chrome's labels and titles, as the app configured them. */
144
+ val fontFamily: String? = config.optString("fontFamily").takeUnless { config.isNull("fontFamily") || it.isBlank() }
145
+ val headingFontFamily: String? = config.optString("headingFontFamily").takeUnless { config.isNull("headingFontFamily") || it.isBlank() }
146
+
140
147
  /** The app's site (`devServerURL`): its origin answers paths no screen draws. */
141
148
  val site: Uri? = config.optString("devServerURL").takeIf { it.isNotBlank() }?.let(Uri::parse)
142
149
  val siteOrigin: String? = site?.let { uri ->
@@ -789,6 +796,7 @@ internal class CraftNativeChrome(
789
796
  context: Context,
790
797
  private val tabs: List<CraftNativeTab>,
791
798
  private val tabBarAppearance: CraftNativeTabBarAppearance = CraftNativeTabBarAppearance(),
799
+ private val fonts: CraftNativeChromeFonts = CraftNativeChromeFonts(),
792
800
  ) : LinearLayout(context) {
793
801
  val content = FrameLayout(context)
794
802
  private val bar = LinearLayout(context)
@@ -858,7 +866,7 @@ internal class CraftNativeChrome(
858
866
  leadingButtons.gravity = Gravity.CENTER_VERTICAL
859
867
  bar.addView(leadingButtons, LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, dp(56)))
860
868
  titleView.textSize = 20f
861
- titleView.typeface = Typeface.create("sans-serif-medium", Typeface.NORMAL)
869
+ titleView.typeface = fonts.heading?.let { Typeface.create(it, Typeface.BOLD) } ?: Typeface.create("sans-serif-medium", Typeface.NORMAL)
862
870
  titleView.isSingleLine = true
863
871
  titleView.ellipsize = TextUtils.TruncateAt.END
864
872
  titleView.setPadding(dp(12), 0, dp(8), 0)
@@ -868,7 +876,7 @@ internal class CraftNativeChrome(
868
876
  bar.addView(buttons, LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, dp(56)))
869
877
  addView(bar, LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, dp(56)))
870
878
  largeTitleView.textSize = 32f
871
- largeTitleView.typeface = Typeface.create("sans-serif", Typeface.BOLD)
879
+ largeTitleView.typeface = Typeface.create(fonts.heading ?: Typeface.create("sans-serif", Typeface.NORMAL), Typeface.BOLD)
872
880
  largeTitleView.isSingleLine = true
873
881
  largeTitleView.ellipsize = TextUtils.TruncateAt.END
874
882
  largeTitleView.setPadding(dp(16), 0, dp(16), dp(8))
@@ -907,6 +915,7 @@ internal class CraftNativeChrome(
907
915
  label.importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO
908
916
  label.text = tab.title
909
917
  label.textSize = 12f
918
+ fonts.body?.let { label.typeface = it }
910
919
  label.fontFeatureSettings = "tnum"
911
920
  label.gravity = Gravity.CENTER
912
921
  label.isSingleLine = true
@@ -1199,6 +1208,7 @@ internal class CraftNativeChrome(
1199
1208
  val vector = if (symbol.isNotBlank()) CraftNativeIcons.drawable(context, null, symbol, tint) else null
1200
1209
  view.text = if (vector != null) "" else if (symbol.isNotBlank()) CraftNativeSymbols.glyph(symbol) else title
1201
1210
  view.textSize = if (symbol.isNotBlank()) 22f else 16f
1211
+ if (symbol.isBlank()) fonts.body?.let { view.typeface = it }
1202
1212
  if (vector != null) {
1203
1213
  view.foreground = android.graphics.drawable.InsetDrawable(vector, dp(10), dp(15), dp(10), dp(15))
1204
1214
  view.minWidth = dp(46)
@@ -4,12 +4,16 @@ import android.Manifest
4
4
  import android.app.NotificationChannel
5
5
  import android.app.NotificationManager
6
6
  import android.app.Service
7
+ import android.content.BroadcastReceiver
7
8
  import android.content.Context
8
9
  import android.content.Intent
10
+ import android.content.IntentFilter
9
11
  import android.content.pm.PackageManager
12
+ import android.os.BatteryManager
10
13
  import android.os.Build
11
14
  import android.os.IBinder
12
15
  import android.os.Looper
16
+ import android.os.PowerManager
13
17
  import androidx.core.app.NotificationCompat
14
18
  import androidx.core.content.ContextCompat
15
19
  import {{PACKAGE_NAME}}.R
@@ -112,14 +116,71 @@ object CraftLocationRecordingStore {
112
116
  }
113
117
 
114
118
  @Synchronized
115
- fun start(context: Context, id: String, startedAt: Long) {
119
+ fun start(context: Context, id: String, startedAt: Long, powerMode: String = "default") {
116
120
  recover(context)
117
121
  if (isActive(context)) throw CraftRecordingException("RECORDING_ALREADY_ACTIVE", "A location recording is already active")
118
122
  if (id == recordingId(context) || archive(context, id).exists() || acknowledged(context, id))
119
123
  throw CraftRecordingException("RECORDING_CONFLICT", "Recording IDs cannot be reused")
120
124
  archiveCompleted(context)
121
125
  file(context).writeText("")
122
- edit(context) { it.putString("id", id).putBoolean("active", true).putBoolean("paused", false).putLong("startedAt", startedAt) }
126
+ edit(context) { it.putString("id", id).putBoolean("active", true).putBoolean("paused", false).putLong("startedAt", startedAt).putString("powerMode", powerMode).remove("powerSaver") }
127
+ }
128
+
129
+ private val POWER_MODES = setOf("default", "saver", "auto")
130
+
131
+ /** A mode the page may ask for; `fallback` when the option is absent or null. */
132
+ private fun requestedPowerMode(options: JSONObject?, fallback: String?): String {
133
+ if (options == null || !options.has("powerMode") || options.isNull("powerMode"))
134
+ return fallback ?: throw CraftRecordingException("INVALID_ARGUMENT", "powerMode must be 'default', 'saver' or 'auto'")
135
+ val mode = options.opt("powerMode")
136
+ if (mode !is String || mode !in POWER_MODES) throw CraftRecordingException("INVALID_ARGUMENT", "powerMode must be 'default', 'saver' or 'auto'")
137
+ return mode
138
+ }
139
+
140
+ /**
141
+ * The mode the recording was asked for (`default`, `saver` or `auto`):
142
+ * `startRecording({ powerMode })`, changed by `setRecordingPowerMode`.
143
+ * A recording stored by an older build kept a `powerSaver` flag instead.
144
+ */
145
+ @Synchronized
146
+ fun powerMode(context: Context): String {
147
+ val values = prefs(context)
148
+ return values.getString("powerMode", null)?.takeIf { it in POWER_MODES }
149
+ ?: if (values.getBoolean("powerSaver", false)) "saver" else "default"
150
+ }
151
+
152
+ /**
153
+ * What the recorder uses right now: `saver` spaces fixes out and batches
154
+ * them, and `auto` is a saver while the device is saving power (Battery
155
+ * Saver on, or under 20% charge and not plugged in). Never outside a recording.
156
+ */
157
+ @Synchronized
158
+ fun isPowerSaver(context: Context): Boolean {
159
+ if (!isActive(context)) return false
160
+ return when (powerMode(context)) {
161
+ "saver" -> true
162
+ "auto" -> deviceIsSavingPower(context)
163
+ else -> false
164
+ }
165
+ }
166
+
167
+ fun deviceIsSavingPower(context: Context): Boolean {
168
+ if (context.getSystemService(PowerManager::class.java)?.isPowerSaveMode == true) return true
169
+ // ACTION_BATTERY_CHANGED is sticky: a null receiver reads it right now.
170
+ val battery = context.registerReceiver(null, IntentFilter(Intent.ACTION_BATTERY_CHANGED)) ?: return false
171
+ val level = battery.getIntExtra(BatteryManager.EXTRA_LEVEL, -1)
172
+ val scale = battery.getIntExtra(BatteryManager.EXTRA_SCALE, -1)
173
+ val pluggedIn = battery.getIntExtra(BatteryManager.EXTRA_PLUGGED, 0) != 0
174
+ return level >= 0 && scale > 0 && !pluggedIn && level * 100 < scale * 20
175
+ }
176
+
177
+ @Synchronized
178
+ fun updatePowerMode(context: Context, options: JSONObject?): JSONObject {
179
+ requireOwner(context, options)
180
+ val mode = requestedPowerMode(options, null)
181
+ if (!isActive(context)) throw CraftRecordingException("NO_ACTIVE_RECORDING", "No active location recording")
182
+ edit(context) { it.putString("powerMode", mode).remove("powerSaver") }
183
+ return state(context)
123
184
  }
124
185
 
125
186
  @Synchronized
@@ -138,7 +199,7 @@ object CraftLocationRecordingStore {
138
199
  val stopped = state(context, includeLocations = true)
139
200
  commit(context, false, false)
140
201
  listener = null
141
- return stopped.put("active", false).put("paused", false)
202
+ return stopped.put("active", false).put("paused", false).put("effectivePowerMode", "default")
142
203
  }
143
204
  @Synchronized fun stop(context: Context) { finish(context, null) }
144
205
  @Synchronized
@@ -175,11 +236,21 @@ object CraftLocationRecordingStore {
175
236
  if (requested !is String || requested.isBlank()) throw CraftRecordingException("INVALID_ARGUMENT", "recordingId must be a nonempty recording ID")
176
237
  requested
177
238
  } else java.util.UUID.randomUUID().toString()
178
- start(context, id, System.currentTimeMillis())
239
+ start(context, id, System.currentTimeMillis(), requestedPowerMode(options, "default"))
179
240
  try { ContextCompat.startForegroundService(context, Intent(context, LocationRecordingService::class.java).setAction(LocationRecordingService.ACTION_START).putExtra("recordingId", id)) }
180
241
  catch (error: Exception) { finish(context, JSONObject().put("expectedId", id)); throw error }
181
242
  state(context)
182
243
  }
244
+ "setRecordingPowerMode" -> {
245
+ val previous = powerMode(context)
246
+ val updated = updatePowerMode(context, options)
247
+ // The running service re-requests its updates for the new spacing.
248
+ // If Android refuses to start it from the background, the stored
249
+ // mode is still what the service takes up on its next start.
250
+ runCatching { ContextCompat.startForegroundService(context, Intent(context, LocationRecordingService::class.java).setAction(LocationRecordingService.ACTION_START).putExtra("recordingId", recordingId(context))) }
251
+ .onFailure { android.util.Log.w("CraftLocation", "Power mode $previous -> ${powerMode(context)} waits for the service", it) }
252
+ updated
253
+ }
183
254
  "pauseRecording" -> updatePause(context, true, options)
184
255
  "resumeRecording" -> {
185
256
  val permitted = ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED || ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED
@@ -270,6 +341,8 @@ object CraftLocationRecordingStore {
270
341
  put("paused", values.getBoolean("paused", false))
271
342
  put("startedAt", values.getLong("startedAt", 0).takeIf { it > 0 } ?: JSONObject.NULL)
272
343
  put("sampleCount", locations.length())
344
+ put("powerMode", powerMode(context))
345
+ put("effectivePowerMode", if (isPowerSaver(context)) "saver" else "default")
273
346
  if (includeLocations) put("locations", locations)
274
347
  }
275
348
  }
@@ -279,7 +352,9 @@ class LocationRecordingService : Service() {
279
352
  private val client by lazy { LocationServices.getFusedLocationProviderClient(this) }
280
353
  private var callback: LocationCallback? = null
281
354
  private var callbackRecordingId: String? = null
355
+ private var callbackSaver = false
282
356
  private var serviceStartId = 0
357
+ private var powerReceiver: BroadcastReceiver? = null
283
358
 
284
359
  override fun onCreate() {
285
360
  super.onCreate()
@@ -337,7 +412,13 @@ class LocationRecordingService : Service() {
337
412
 
338
413
  private fun startUpdates(): Boolean {
339
414
  val id = CraftLocationRecordingStore.recordingId(this) ?: return false
340
- if (callback != null && callbackRecordingId == id) return true
415
+ // `auto` can change what the recorder should do without a call: the
416
+ // request is only rebuilt when the mode in use is not the one it was made for.
417
+ val saver = CraftLocationRecordingStore.isPowerSaver(this)
418
+ if (callback != null && callbackRecordingId == id && callbackSaver == saver) {
419
+ syncPowerReceiver()
420
+ return true
421
+ }
341
422
  stopUpdates()
342
423
  if (!CraftLocationRecordingStore.isActive(this)) return false
343
424
  val hasForegroundLocation =
@@ -347,9 +428,14 @@ class LocationRecordingService : Service() {
347
428
  runCatching { CraftLocationRecordingStore.finish(this, JSONObject().put("expectedId", id)) }
348
429
  return false
349
430
  }
431
+ // Saving power (an ultra): fixes at least 5 m apart and delivered in
432
+ // batches of up to 20 s, each keeping its own time. 5 m stays under
433
+ // the route's 30 s pause gap at any speed that counts as moving
434
+ // (0.2 m/s), so distance and moving time come out as at full rate.
350
435
  val request = LocationRequest.Builder(Priority.PRIORITY_HIGH_ACCURACY, 5_000)
351
436
  .setMinUpdateIntervalMillis(2_000)
352
- .setMaxUpdateDelayMillis(10_000)
437
+ .setMaxUpdateDelayMillis(if (saver) 20_000 else 10_000)
438
+ .apply { if (saver) setMinUpdateDistanceMeters(5f) }
353
439
  .build()
354
440
  val nextCallback = object : LocationCallback() {
355
441
  override fun onLocationResult(result: LocationResult) {
@@ -358,6 +444,8 @@ class LocationRecordingService : Service() {
358
444
  }
359
445
  callback = nextCallback
360
446
  callbackRecordingId = id
447
+ callbackSaver = saver
448
+ syncPowerReceiver()
361
449
  return try {
362
450
  client.requestLocationUpdates(request, nextCallback, Looper.getMainLooper())
363
451
  .addOnFailureListener {
@@ -382,6 +470,37 @@ class LocationRecordingService : Service() {
382
470
  callback?.let { client.removeLocationUpdates(it) }
383
471
  callback = null
384
472
  callbackRecordingId = null
473
+ callbackSaver = false
474
+ powerReceiver?.let { runCatching { unregisterReceiver(it) } }
475
+ powerReceiver = null
476
+ }
477
+
478
+ /**
479
+ * `powerMode: 'auto'` follows Battery Saver and the charge, so the service
480
+ * listens for the platform's changes while a recording runs in that mode,
481
+ * and for no other recording.
482
+ */
483
+ private fun syncPowerReceiver() {
484
+ val wanted = CraftLocationRecordingStore.powerMode(this) == "auto"
485
+ if (!wanted) {
486
+ powerReceiver?.let { runCatching { unregisterReceiver(it) } }
487
+ powerReceiver = null
488
+ return
489
+ }
490
+ if (powerReceiver != null) return
491
+ val receiver = object : BroadcastReceiver() {
492
+ override fun onReceive(context: Context?, intent: Intent?) {
493
+ synchronized(CraftLocationRecordingStore) {
494
+ if (callback != null && CraftLocationRecordingStore.isPowerSaver(this@LocationRecordingService) != callbackSaver) startUpdates()
495
+ }
496
+ }
497
+ }
498
+ powerReceiver = receiver
499
+ val filter = IntentFilter().apply {
500
+ addAction(PowerManager.ACTION_POWER_SAVE_MODE_CHANGED)
501
+ addAction(Intent.ACTION_BATTERY_CHANGED)
502
+ }
503
+ ContextCompat.registerReceiver(this, receiver, filter, ContextCompat.RECEIVER_NOT_EXPORTED)
385
504
  }
386
505
 
387
506
  override fun onDestroy() {
@@ -2677,6 +2677,24 @@ internal fun craftNativeInheritFontFamily(style: JSONObject, inherited: String?)
2677
2677
  }
2678
2678
 
2679
2679
  /** CSS family fallback order, using bundled app fonts before platform families. */
2680
+ /**
2681
+ * The face a CSS stack gives the app's own chrome (title, tab and button
2682
+ * labels): the first family packaged with the app, or null for the system
2683
+ * face when a generic family (`system-ui`, `sans-serif`) comes first or none is
2684
+ * packaged. Screens resolve the same stacks with `craftNativeFontFamily`.
2685
+ */
2686
+ internal fun craftNativeChromeTypeface(stack: String?, bundledFonts: Map<String, Typeface>): Typeface? {
2687
+ for (part in stack.orEmpty().split(',')) {
2688
+ val name = part.trim().trim('"', '\'')
2689
+ when (name.lowercase()) {
2690
+ "" -> continue
2691
+ "sans-serif", "ui-sans-serif", "system-ui", "-apple-system", "serif", "ui-serif", "monospace", "ui-monospace" -> return null
2692
+ else -> bundledFonts[name]?.let { return it }
2693
+ }
2694
+ }
2695
+ return null
2696
+ }
2697
+
2680
2698
  internal fun craftNativeFontFamily(family: String?, bundledFonts: Map<String, Typeface> = emptyMap()): Typeface =
2681
2699
  family?.split(',')?.asSequence()?.map { it.trim().trim('"', '\'') }?.mapNotNull { name ->
2682
2700
  bundledFonts[name] ?: when (name.lowercase()) {
@@ -4841,7 +4859,9 @@ class MainActivity : AppCompatActivity() {
4841
4859
  }
4842
4860
 
4843
4861
  private fun installChrome() {
4844
- val bars = CraftNativeChrome(this, routes.tabs, routes.tabBarAppearance)
4862
+ val bodyFont = craftNativeChromeTypeface(routes.fontFamily, bundledFonts)
4863
+ val bars = CraftNativeChrome(this, routes.tabs, routes.tabBarAppearance,
4864
+ CraftNativeChromeFonts(bodyFont, craftNativeChromeTypeface(routes.headingFontFamily, bundledFonts) ?: bodyFont))
4845
4865
  bars.layoutParams = FrameLayout.LayoutParams(-1, -1)
4846
4866
  bars.onBack = { onBackPressedDispatcherCompat() }
4847
4867
  bars.onAccessory = { path -> navigateOpen(path) }
@@ -558,10 +558,42 @@ export interface LocationOptions {
558
558
  export interface RecordingStartOptions {
559
559
  recordingId?: string;
560
560
  }
561
+ /**
562
+ * How hard a route recording works the GPS. `'saver'` is for a long effort (an
563
+ * ultra): fixes come at least 5 m apart rather than every second, and while
564
+ * the pace holds steady iOS asks for ten-metre accuracy instead of the best it
565
+ * can do, going back to the best at once when the pace or the signal changes.
566
+ * Fixes stay under 30 s apart at any speed that counts as moving, so distance
567
+ * and moving time come out as they would at full rate. Android asks for the
568
+ * same spacing and batches delivery.
569
+ *
570
+ * `'auto'` saves power only while the device wants it: iOS Low Power Mode or
571
+ * Android Battery Saver is on, or the battery is under 20% and not charging.
572
+ * The host re-checks whenever the platform reports a change, so a recording
573
+ * moves between `'saver'` and `'default'` by itself. Read which one it is using
574
+ * from `effectivePowerMode` on the recording's state.
575
+ */
576
+ export type LocationRecordingPowerMode = 'default' | 'saver' | 'auto';
577
+ /** What a recording is actually using right now: `'auto'` resolves to one of these. */
578
+ export type LocationRecordingEffectivePowerMode = 'default' | 'saver';
579
+ export interface LocationRecordingPowerOptions {
580
+ powerMode?: LocationRecordingPowerMode;
581
+ }
582
+ /**
583
+ * Change the power mode of the recording that is running, fenced to its ID
584
+ * like pause, resume and stop.
585
+ */
586
+ export interface LocationRecordingPowerModeOptions extends RecordingOwnershipOptions {
587
+ powerMode: LocationRecordingPowerMode;
588
+ }
561
589
  /** Fence a recording operation to the ID returned by start or explicit recovery. */
562
590
  export interface RecordingOwnershipOptions {
563
591
  expectedId: string;
564
592
  }
593
+ /** Reading a route back: `since` (epoch ms) asks only for the fixes recorded after it. */
594
+ export interface LocationRecordingReadOptions extends RecordingOwnershipOptions {
595
+ since?: number;
596
+ }
565
597
  /** Secure IDs supplied by the platform, including WebView-free mobile hosts. */
566
598
  export declare const crypto: {
567
599
  randomUUID(): Promise<string>;
@@ -584,6 +616,10 @@ export interface LocationRecordingState {
584
616
  paused: boolean;
585
617
  startedAt: number | null;
586
618
  sampleCount?: number;
619
+ /** The mode asked for (`'default'` on a host that predates power modes). */
620
+ powerMode?: LocationRecordingPowerMode;
621
+ /** The mode in use now. `'default'` once the recording has stopped. */
622
+ effectivePowerMode?: LocationRecordingEffectivePowerMode;
587
623
  }
588
624
  export interface LocationRecordingResult extends LocationRecordingState {
589
625
  locations: Location[];
@@ -609,6 +645,8 @@ export interface LocationRecordingResult extends LocationRecordingState {
609
645
  export declare const location: {
610
646
  supportsRecordingOwnership: () => boolean;
611
647
  supportsRecordingIntentIdentity: () => boolean;
648
+ /** `setRecordingPowerMode()` and `powerMode: 'auto'` work on this host. */
649
+ supportsRecordingPowerMode: () => boolean;
612
650
  /**
613
651
  * Get current position.
614
652
  *
@@ -630,13 +668,32 @@ export declare const location: {
630
668
  * @param watchId - Watch ID returned from watchPosition
631
669
  */
632
670
  clearWatch(watchId: number): void;
633
- startRecording(options?: LocationOptions & RecordingStartOptions): Promise<LocationRecordingState>;
671
+ startRecording(options?: LocationOptions & RecordingStartOptions & LocationRecordingPowerOptions): Promise<LocationRecordingState>;
672
+ /**
673
+ * Change how hard the running recording works the GPS, for example to
674
+ * `'saver'` while the screen shows nothing but a countdown and back to
675
+ * `'default'` afterwards, or to `'auto'` to follow the device's own power
676
+ * saving. Resolves with the recording's state, which reports the mode asked
677
+ * for (`powerMode`) and the one in use (`effectivePowerMode`).
678
+ *
679
+ * Rejects with `NO_ACTIVE_RECORDING` when nothing is recording,
680
+ * `RECORDING_CONFLICT` when `expectedId` is not the recording that is
681
+ * running, `INVALID_ARGUMENT` for an unknown mode, and
682
+ * `RECORDING_POWER_MODE_UNSUPPORTED` on a host built before this existed
683
+ * (check `supportsRecordingPowerMode()`).
684
+ */
685
+ setRecordingPowerMode(options: LocationRecordingPowerModeOptions): Promise<LocationRecordingState>;
634
686
  pauseRecording(options?: RecordingOwnershipOptions): Promise<LocationRecordingState>;
635
687
  resumeRecording(options?: RecordingOwnershipOptions): Promise<LocationRecordingState>;
636
688
  stopRecording(options?: RecordingOwnershipOptions): Promise<LocationRecordingResult>;
637
689
  getRecordingState(options?: RecordingOwnershipOptions): Promise<LocationRecordingState>;
638
690
  acknowledgeRecording(options: RecordingOwnershipOptions): Promise<boolean>;
639
- readRecording(options?: RecordingOwnershipOptions): Promise<Location[]>;
691
+ /**
692
+ * The recording's fixes. With `since`, iOS reads only the fixes after it
693
+ * from the end of the track; a host that does not know `since` answers with
694
+ * every fix, so a caller skips those it already has.
695
+ */
696
+ readRecording(options?: LocationRecordingReadOptions): Promise<Location[]>;
640
697
  };
641
698
  /**
642
699
  * Share options.
package/dist/cli.js CHANGED
@@ -3047,7 +3047,7 @@ function pinCraftNativeDependency(packagePath, craftVersion) {
3047
3047
  `);
3048
3048
  }
3049
3049
  // package.json
3050
- var version = "0.0.396";
3050
+ var version = "0.0.408";
3051
3051
 
3052
3052
  // bin/cli.ts
3053
3053
  var spawnedFrom = process6.env[CRAFT_CLI_SPAWN_MARKER];
package/dist/index.cjs CHANGED
@@ -375,7 +375,7 @@ function craftBinaryNotFoundMessage(triedPath) {
375
375
  `);
376
376
  }
377
377
  // package.json
378
- var version = "0.0.396";
378
+ var version = "0.0.408";
379
379
 
380
380
  // src/sdk-version.ts
381
381
  var SDK_VERSION = version;
@@ -5491,6 +5491,7 @@ var secureStorage = {
5491
5491
  Object.keys(localStorage).filter((k) => k.startsWith("secure_")).forEach((k) => localStorage.removeItem(k));
5492
5492
  }
5493
5493
  };
5494
+ var RECORDING_POWER_MODES = ["default", "saver", "auto"];
5494
5495
  function storageHost(method) {
5495
5496
  const host = getCraftRoot()?.storage;
5496
5497
  if (typeof host?.[method] !== "function")
@@ -5554,6 +5555,23 @@ function recordingIntentSupported(namespace) {
5554
5555
  const advertised = Array.isArray(caps) ? caps.includes(flag) : caps?.[flag] === true;
5555
5556
  return advertised && recordingOwnershipSupported(namespace) && typeof root?.[namespace]?.startRecording === "function";
5556
5557
  }
5558
+ function recordingPowerModeSupported() {
5559
+ const root = getCraftRoot();
5560
+ const caps = root?.capabilities;
5561
+ const advertised = Array.isArray(caps) ? caps.includes("locationRecordingPowerMode") : caps?.locationRecordingPowerMode === true;
5562
+ return advertised && recordingOwnershipSupported("location") && typeof root?.location?.setRecordingPowerMode === "function";
5563
+ }
5564
+ function validatePowerMode(value) {
5565
+ if (typeof value !== "string" || !RECORDING_POWER_MODES.includes(value))
5566
+ throw Object.assign(new Error("powerMode must be 'default', 'saver' or 'auto'"), { code: "INVALID_ARGUMENT" });
5567
+ return value;
5568
+ }
5569
+ function validateRecordingPowerStart(options) {
5570
+ if (!Object.prototype.hasOwnProperty.call(options, "powerMode") || options.powerMode === undefined)
5571
+ return;
5572
+ if (validatePowerMode(options.powerMode) === "auto" && getCraftRoot() && !recordingPowerModeSupported())
5573
+ throw Object.assign(new Error("This app build cannot start a recording in powerMode 'auto'"), { code: "RECORDING_POWER_MODE_UNSUPPORTED" });
5574
+ }
5557
5575
  function validateRecordingStart(namespace, options) {
5558
5576
  if (!Object.prototype.hasOwnProperty.call(options, "recordingId"))
5559
5577
  return;
@@ -5584,10 +5602,20 @@ var webLocationRecording = {
5584
5602
  startedAt: null,
5585
5603
  locations: []
5586
5604
  };
5605
+ var webLocationPowerMode = "default";
5606
+ function webLocationRecordingState() {
5607
+ return {
5608
+ ...webLocationRecording,
5609
+ sampleCount: webLocationRecording.locations.length,
5610
+ powerMode: webLocationPowerMode,
5611
+ effectivePowerMode: webLocationRecording.active && webLocationPowerMode === "saver" ? "saver" : "default"
5612
+ };
5613
+ }
5587
5614
  var webLocationRecordingWatchId = null;
5588
5615
  var location = {
5589
5616
  supportsRecordingOwnership: () => recordingOwnershipSupported("location"),
5590
5617
  supportsRecordingIntentIdentity: () => recordingIntentSupported("location"),
5618
+ supportsRecordingPowerMode: () => recordingPowerModeSupported(),
5591
5619
  async getCurrentPosition(options = {}) {
5592
5620
  const craft = getCraftMobile();
5593
5621
  if (typeof window !== "undefined" && craft?.location) {
@@ -5637,6 +5665,7 @@ var location = {
5637
5665
  },
5638
5666
  async startRecording(options = {}) {
5639
5667
  validateRecordingStart("location", options);
5668
+ validateRecordingPowerStart(options);
5640
5669
  const craft = getCraftMobile();
5641
5670
  if (typeof window !== "undefined" && craft?.location?.startRecording)
5642
5671
  return verifyRecordingStart(options, await craft.location.startRecording(options));
@@ -5651,11 +5680,27 @@ var location = {
5651
5680
  startedAt: Date.now(),
5652
5681
  locations: []
5653
5682
  };
5683
+ webLocationPowerMode = options.powerMode ?? "default";
5654
5684
  webLocationRecordingWatchId = location.watchPosition((position) => {
5655
5685
  if (!webLocationRecording.paused)
5656
5686
  webLocationRecording.locations.push(position);
5657
5687
  }, options);
5658
- return { ...webLocationRecording, sampleCount: 0 };
5688
+ return webLocationRecordingState();
5689
+ },
5690
+ async setRecordingPowerMode(options) {
5691
+ if (!options || !Object.prototype.hasOwnProperty.call(options, "expectedId"))
5692
+ throw Object.assign(new Error("setRecordingPowerMode needs expectedId"), { code: "INVALID_ARGUMENT" });
5693
+ validateRecordingOwnership("location", options);
5694
+ const powerMode = validatePowerMode(options.powerMode);
5695
+ if (getCraftRoot() && !recordingPowerModeSupported())
5696
+ throw Object.assign(new Error("This app build cannot change the recording power mode"), { code: "RECORDING_POWER_MODE_UNSUPPORTED" });
5697
+ const craft = getCraftMobile();
5698
+ if (typeof window !== "undefined" && craft?.location?.setRecordingPowerMode)
5699
+ return craft.location.setRecordingPowerMode({ expectedId: options.expectedId, powerMode });
5700
+ if (!webLocationRecording.active)
5701
+ throw Object.assign(new Error("No active location recording"), { code: "NO_ACTIVE_RECORDING" });
5702
+ webLocationPowerMode = powerMode;
5703
+ return webLocationRecordingState();
5659
5704
  },
5660
5705
  async pauseRecording(options) {
5661
5706
  validateRecordingOwnership("location", options);
@@ -5663,7 +5708,7 @@ var location = {
5663
5708
  if (typeof window !== "undefined" && craft?.location?.pauseRecording)
5664
5709
  return craft.location.pauseRecording(options);
5665
5710
  webLocationRecording.paused = true;
5666
- return { ...webLocationRecording, sampleCount: webLocationRecording.locations.length };
5711
+ return webLocationRecordingState();
5667
5712
  },
5668
5713
  async resumeRecording(options) {
5669
5714
  validateRecordingOwnership("location", options);
@@ -5671,7 +5716,7 @@ var location = {
5671
5716
  if (typeof window !== "undefined" && craft?.location?.resumeRecording)
5672
5717
  return craft.location.resumeRecording(options);
5673
5718
  webLocationRecording.paused = false;
5674
- return { ...webLocationRecording, sampleCount: webLocationRecording.locations.length };
5719
+ return webLocationRecordingState();
5675
5720
  },
5676
5721
  async stopRecording(options) {
5677
5722
  validateRecordingOwnership("location", options);
@@ -5684,14 +5729,14 @@ var location = {
5684
5729
  }
5685
5730
  webLocationRecording.active = false;
5686
5731
  webLocationRecording.paused = false;
5687
- return { ...webLocationRecording, locations: [...webLocationRecording.locations] };
5732
+ return { ...webLocationRecordingState(), locations: [...webLocationRecording.locations] };
5688
5733
  },
5689
5734
  async getRecordingState(options) {
5690
5735
  validateRecordingOwnership("location", options);
5691
5736
  const craft = getCraftMobile();
5692
5737
  if (typeof window !== "undefined" && craft?.location?.getRecordingState)
5693
5738
  return craft.location.getRecordingState(options);
5694
- return { ...webLocationRecording, sampleCount: webLocationRecording.locations.length };
5739
+ return webLocationRecordingState();
5695
5740
  },
5696
5741
  async acknowledgeRecording(options) {
5697
5742
  if (!options || !Object.prototype.hasOwnProperty.call(options, "expectedId"))
@@ -5703,6 +5748,7 @@ var location = {
5703
5748
  if (webLocationRecording.active)
5704
5749
  throw Object.assign(new Error("The recording is still active"), { code: "RECORDING_ALREADY_ACTIVE" });
5705
5750
  webLocationRecording = { id: null, active: false, paused: false, startedAt: null, locations: [] };
5751
+ webLocationPowerMode = "default";
5706
5752
  return true;
5707
5753
  },
5708
5754
  async readRecording(options) {
@@ -5710,7 +5756,8 @@ var location = {
5710
5756
  const craft = getCraftMobile();
5711
5757
  if (typeof window !== "undefined" && craft?.location?.readRecording)
5712
5758
  return craft.location.readRecording(options);
5713
- return [...webLocationRecording.locations];
5759
+ const since = options?.since;
5760
+ return typeof since === "number" && Number.isFinite(since) ? webLocationRecording.locations.filter((location) => location.timestamp > since) : [...webLocationRecording.locations];
5714
5761
  }
5715
5762
  };
5716
5763
  var share = {