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.
- package/README.md +7 -0
- package/dist/android/templates/CraftBridge.kt.template +2 -0
- package/dist/android/templates/CraftNative.kt.template +7 -0
- package/dist/android/templates/CraftNativeFlatList.kt.template +7 -0
- package/dist/android/templates/CraftNativeMapGoogle.kt.template +3 -0
- package/dist/android/templates/CraftNativeMapLibre.kt.template +3 -0
- package/dist/android/templates/CraftNativeRefreshableFlatList.kt.template +9 -0
- package/dist/android/templates/CraftNativeServices.kt.template +2 -1
- package/dist/android/templates/CraftNativeShell.kt.template +12 -2
- package/dist/android/templates/LocationRecordingService.kt.template +125 -6
- package/dist/android/templates/MainActivityNative.kt.template +338 -21
- package/dist/api/mobile.d.ts +59 -2
- package/dist/cli.js +1 -1
- package/dist/components/index.d.ts +16 -0
- package/dist/index.cjs +54 -7
- package/dist/index.d.cts +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +54 -7
- package/dist/ios/src/index.d.ts +7 -0
- package/dist/ios/templates/CraftApp.swift +268 -12
- package/dist/ios/templates/CraftHybrid.swift +1 -0
- package/dist/ios/templates/CraftNativeActions.swift +3 -1
- package/dist/ios/templates/CraftNativeFlatList.swift +4 -0
- package/dist/ios/templates/CraftNativeMutation.swift +27 -2
- package/dist/ios/templates/CraftNativeScreen.swift +293 -44
- package/dist/ios/templates/CraftNativeUI.swift +89 -0
- package/dist/ios/templates/testing/DriverTests.swift.template +17 -1
- package/dist/macos/src/index.d.ts +14 -3
- package/dist/macos/src/index.js +124 -27
- package/dist/macos/src/toolchain.d.ts +56 -0
- package/dist/macos/templates/CraftMacApplication.swift +54 -9
- package/dist/macos/templates/CraftMacRenderer.swift +376 -18
- package/dist/macos/templates/CraftMacRuntime.swift +114 -1
- package/dist/macos/templates/CraftMacWebView.swift +416 -0
- package/dist/macos/templates/CraftNativeMutation.swift +27 -2
- package/dist/mobile.js +53 -6
- 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
|
-
|
|
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() {
|