craft-native 0.0.395 → 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.
Files changed (37) hide show
  1. package/README.md +7 -0
  2. package/dist/android/templates/CraftBridge.kt.template +2 -0
  3. package/dist/android/templates/CraftNative.kt.template +7 -0
  4. package/dist/android/templates/CraftNativeFlatList.kt.template +7 -0
  5. package/dist/android/templates/CraftNativeMapGoogle.kt.template +3 -0
  6. package/dist/android/templates/CraftNativeMapLibre.kt.template +3 -0
  7. package/dist/android/templates/CraftNativeRefreshableFlatList.kt.template +9 -0
  8. package/dist/android/templates/CraftNativeServices.kt.template +2 -1
  9. package/dist/android/templates/CraftNativeShell.kt.template +12 -2
  10. package/dist/android/templates/LocationRecordingService.kt.template +125 -6
  11. package/dist/android/templates/MainActivityNative.kt.template +338 -21
  12. package/dist/api/mobile.d.ts +59 -2
  13. package/dist/cli.js +1 -1
  14. package/dist/components/index.d.ts +16 -0
  15. package/dist/index.cjs +54 -7
  16. package/dist/index.d.cts +1 -1
  17. package/dist/index.d.ts +1 -1
  18. package/dist/index.js +54 -7
  19. package/dist/ios/src/index.d.ts +7 -0
  20. package/dist/ios/templates/CraftApp.swift +268 -12
  21. package/dist/ios/templates/CraftHybrid.swift +1 -0
  22. package/dist/ios/templates/CraftNativeActions.swift +3 -1
  23. package/dist/ios/templates/CraftNativeFlatList.swift +4 -0
  24. package/dist/ios/templates/CraftNativeMutation.swift +27 -2
  25. package/dist/ios/templates/CraftNativeScreen.swift +293 -44
  26. package/dist/ios/templates/CraftNativeUI.swift +89 -0
  27. package/dist/ios/templates/testing/DriverTests.swift.template +17 -1
  28. package/dist/macos/src/index.d.ts +14 -3
  29. package/dist/macos/src/index.js +124 -27
  30. package/dist/macos/src/toolchain.d.ts +56 -0
  31. package/dist/macos/templates/CraftMacApplication.swift +54 -9
  32. package/dist/macos/templates/CraftMacRenderer.swift +376 -18
  33. package/dist/macos/templates/CraftMacRuntime.swift +114 -1
  34. package/dist/macos/templates/CraftMacWebView.swift +416 -0
  35. package/dist/macos/templates/CraftNativeMutation.swift +27 -2
  36. package/dist/mobile.js +53 -6
  37. package/package.json +1 -1
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 }
@@ -1,6 +1,7 @@
1
1
  package {{PACKAGE_NAME}}
2
2
 
3
3
  import android.content.Context
4
+ import android.view.MotionEvent
4
5
  import android.view.View
5
6
  import android.view.ViewGroup
6
7
  import android.widget.FrameLayout
@@ -18,6 +19,9 @@ internal typealias CraftNativeRenderItem = (node: JSONObject, identity: String,
18
19
 
19
20
  /** A stable-key RecyclerView host for stx native FlatList nodes. */
20
21
  internal class CraftNativeFlatList(context: Context) : RecyclerView(context) {
22
+ override fun dispatchTouchEvent(event: MotionEvent): Boolean =
23
+ CraftNativePointers.dispatch(this, event) { super.dispatchTouchEvent(it) }
24
+
21
25
  private data class Item(
22
26
  val identity: String,
23
27
  val node: JSONObject,
@@ -227,11 +231,13 @@ internal class CraftNativeFlatList(context: Context) : RecyclerView(context) {
227
231
  .map { it.second }
228
232
 
229
233
  override fun onInterceptTouchEvent(event: android.view.MotionEvent): Boolean {
234
+ if (event.actionMasked != MotionEvent.ACTION_CANCEL && !CraftNativePointers.ownsTouch(this)) return false
230
235
  if (!scrollEnabled) return false
231
236
  return super.onInterceptTouchEvent(event)
232
237
  }
233
238
 
234
239
  override fun onTouchEvent(event: android.view.MotionEvent): Boolean {
240
+ if (event.actionMasked != MotionEvent.ACTION_CANCEL && !CraftNativePointers.ownsTouch(this)) return false
235
241
  if (!scrollEnabled) return false
236
242
  return super.onTouchEvent(event)
237
243
  }
@@ -304,6 +310,7 @@ internal class CraftNativeFlatList(context: Context) : RecyclerView(context) {
304
310
  }
305
311
  var dismissed = false
306
312
  setOnTouchListener { _, event ->
313
+ if (!CraftNativePointers.ownsTouch(this) && event.actionMasked != MotionEvent.ACTION_CANCEL) return@setOnTouchListener false
307
314
  when (event.actionMasked) {
308
315
  android.view.MotionEvent.ACTION_DOWN -> dismissed = false
309
316
  android.view.MotionEvent.ACTION_MOVE -> if (!dismissed) {
@@ -1,5 +1,8 @@
1
1
  /** The Android host for the small, host-neutral native trail map contract. */
2
2
  private class CraftNativeMapView(context: Context) : FrameLayout(context), OnMapReadyCallback {
3
+ override fun dispatchTouchEvent(event: MotionEvent): Boolean =
4
+ CraftNativePointers.dispatch(this, event) { super.dispatchTouchEvent(it) }
5
+
3
6
  private data class PolygonValue(val points: List<LatLng>, val fillColor: Int, val strokeColor: Int, val strokeWidth: Float)
4
7
  private val mapView = MapView(context)
5
8
  private val scaleView = LinearLayout(context).apply {
@@ -1,5 +1,8 @@
1
1
  /** A key-free native Android map. STX geometry never enters a WebView. */
2
2
  internal class CraftNativeMapView(context: Context) : FrameLayout(context), OnMapReadyCallback {
3
+ override fun dispatchTouchEvent(event: MotionEvent): Boolean =
4
+ CraftNativePointers.dispatch(this, event) { super.dispatchTouchEvent(it) }
5
+
3
6
  private val mapView: MapView
4
7
  private var map: MapLibreMap? = null
5
8
  private var destroyed = false
@@ -1,11 +1,20 @@
1
1
  package {{PACKAGE_NAME}}
2
2
 
3
3
  import android.content.Context
4
+ import android.view.MotionEvent
4
5
  import org.json.JSONObject
5
6
  import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
6
7
 
7
8
  /** Swipe-to-refresh host for a native FlatList. */
8
9
  internal class CraftNativeRefreshableFlatList(context: Context) : SwipeRefreshLayout(context) {
10
+ override fun dispatchTouchEvent(event: MotionEvent): Boolean =
11
+ CraftNativePointers.dispatch(this, event) { super.dispatchTouchEvent(it) }
12
+
13
+ override fun onInterceptTouchEvent(event: MotionEvent): Boolean =
14
+ CraftNativePointers.ownsTouch(this) && super.onInterceptTouchEvent(event)
15
+ override fun onTouchEvent(event: MotionEvent): Boolean =
16
+ (event.actionMasked == MotionEvent.ACTION_CANCEL || CraftNativePointers.ownsTouch(this)) && super.onTouchEvent(event)
17
+
9
18
  val list = CraftNativeFlatList(context)
10
19
  private var refreshHandler: (() -> Unit)? = null
11
20
 
@@ -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() {