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 +7 -0
- package/dist/android/templates/CraftBridge.kt.template +2 -0
- package/dist/android/templates/CraftNative.kt.template +7 -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 +21 -1
- package/dist/api/mobile.d.ts +59 -2
- package/dist/cli.js +1 -1
- package/dist/index.cjs +54 -7
- 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/CraftNativeScreen.swift +1 -0
- 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/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 }
|
|
@@ -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() {
|
|
@@ -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
|
|
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) }
|
package/dist/api/mobile.d.ts
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
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 { ...
|
|
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
|
|
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
|
-
|
|
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 = {
|