craft-native 0.0.436 → 0.0.443
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/LICENSE.md +30 -0
- package/README.md +35 -0
- package/dist/android/src/index.d.ts +44 -0
- package/dist/android/src/index.js +82 -3
- package/dist/android/templates/AndroidManifest.xml.template +2 -1
- package/dist/android/templates/CraftHealthServices.kt.template +2 -0
- package/dist/android/templates/CraftNativeCapabilities.kt.template +42 -1
- package/dist/android/templates/CraftNativeMotion.kt.template +269 -0
- package/dist/android/templates/CraftNativeServices.kt.template +6 -1
- package/dist/android/templates/CraftPassiveSteps.kt.template +159 -0
- package/dist/android/templates/CraftRecordingPressure.kt.template +156 -0
- package/dist/android/templates/LocationRecordingService.kt.template +77 -9
- package/dist/android/templates/MainActivity.kt.template +3 -0
- package/dist/android/templates/MainActivityNative.kt.template +132 -1
- package/dist/cli.js +289 -108
- package/dist/components/index.d.ts +25 -0
- package/dist/index.cjs +207 -27
- package/dist/index.d.cts +2 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.js +321 -140
- package/dist/installer-background.d.ts +2 -0
- package/dist/installer-layout.d.ts +1 -0
- package/dist/ios/src/index.d.ts +23 -1
- package/dist/ios/src/index.js +49 -9
- package/dist/ios/templates/CraftApp.swift +14 -0
- package/dist/ios/templates/CraftHealth.swift +2 -1
- package/dist/ios/templates/CraftHealthBackground.swift +308 -0
- package/dist/ios/templates/CraftNativeActions.swift +153 -0
- package/dist/ios/templates/CraftNativePedometer.swift +181 -0
- package/dist/ios/templates/CraftNativeScreen.swift +76 -0
- package/dist/macos/src/index.js +1 -1
- package/dist/macos/src/toolchain.d.ts +1 -1
- package/dist/macos/templates/CraftMacApplication.swift +124 -39
- package/dist/macos/templates/CraftMacRenderer.swift +111 -33
- package/dist/macos/templates/CraftMacRuntime.swift +270 -81
- package/dist/package.d.ts +40 -0
- package/dist/types.d.ts +26 -0
- package/package.json +1 -1
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
package {{PACKAGE_NAME}}
|
|
2
|
+
|
|
3
|
+
import android.content.Context
|
|
4
|
+
import android.hardware.Sensor
|
|
5
|
+
import android.hardware.SensorEvent
|
|
6
|
+
import android.hardware.SensorEventListener
|
|
7
|
+
import android.hardware.SensorManager
|
|
8
|
+
import android.os.Handler
|
|
9
|
+
import android.os.Looper
|
|
10
|
+
import org.json.JSONArray
|
|
11
|
+
import org.json.JSONObject
|
|
12
|
+
|
|
13
|
+
/** Registration owns its listener. Closing an old registration cannot stop a replacement. */
|
|
14
|
+
internal interface CraftMotionRegistration { fun close() }
|
|
15
|
+
internal interface CraftMotionSource {
|
|
16
|
+
fun available(type: Int): Boolean
|
|
17
|
+
fun register(type: Int, periodUs: Int, sample: (FloatArray, Long, Int) -> Unit): CraftMotionRegistration?
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
internal class CraftAndroidMotionSource(context: Context) : CraftMotionSource {
|
|
21
|
+
private val manager = context.getSystemService(Context.SENSOR_SERVICE) as? SensorManager
|
|
22
|
+
override fun available(type: Int): Boolean = manager?.getDefaultSensor(type) != null
|
|
23
|
+
override fun register(type: Int, periodUs: Int, sample: (FloatArray, Long, Int) -> Unit): CraftMotionRegistration? {
|
|
24
|
+
val manager = manager ?: return null
|
|
25
|
+
val sensor = manager.getDefaultSensor(type) ?: return null
|
|
26
|
+
val listener = object : SensorEventListener {
|
|
27
|
+
override fun onSensorChanged(event: SensorEvent) {
|
|
28
|
+
sample(event.values.clone(), event.timestamp, event.accuracy)
|
|
29
|
+
}
|
|
30
|
+
override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) {}
|
|
31
|
+
}
|
|
32
|
+
try {
|
|
33
|
+
if (!manager.registerListener(listener, sensor, periodUs, Handler(Looper.getMainLooper()))) {
|
|
34
|
+
manager.unregisterListener(listener)
|
|
35
|
+
return null
|
|
36
|
+
}
|
|
37
|
+
} catch (error: Exception) {
|
|
38
|
+
manager.unregisterListener(listener)
|
|
39
|
+
throw error
|
|
40
|
+
}
|
|
41
|
+
return object : CraftMotionRegistration {
|
|
42
|
+
override fun close() { manager.unregisterListener(listener) }
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Foreground, route-owned raw readings; deliberately distinct from iOS CMDeviceMotion. */
|
|
48
|
+
internal class CraftNativeMotion(private val source: CraftMotionSource) {
|
|
49
|
+
private data class Kind(val type: Int, val unit: String)
|
|
50
|
+
private val kinds = linkedMapOf(
|
|
51
|
+
"accelerometer" to Kind(Sensor.TYPE_ACCELEROMETER, "m/s²"),
|
|
52
|
+
"gyroscope" to Kind(Sensor.TYPE_GYROSCOPE, "rad/s"),
|
|
53
|
+
"magnetometer" to Kind(Sensor.TYPE_MAGNETIC_FIELD, "µT"),
|
|
54
|
+
"rotationVector" to Kind(Sensor.TYPE_ROTATION_VECTOR, "quaternion"),
|
|
55
|
+
)
|
|
56
|
+
private class Stream { var registration: CraftMotionRegistration? = null }
|
|
57
|
+
private val streams = mutableMapOf<String, Stream>()
|
|
58
|
+
private var closed = false
|
|
59
|
+
var emit: (JSONObject) -> Unit = {}
|
|
60
|
+
|
|
61
|
+
fun perform(method: String, args: JSONArray): CraftNativeCapabilityResult {
|
|
62
|
+
if (closed) return failure("HOST_CLOSED", "Motion host is closed")
|
|
63
|
+
return when (method) {
|
|
64
|
+
"getAvailability" -> CraftNativeCapabilityResult(data = JSONObject().apply {
|
|
65
|
+
kinds.forEach { (name, kind) -> put(name, source.available(kind.type)) }
|
|
66
|
+
})
|
|
67
|
+
"start" -> start(args.optJSONObject(0))
|
|
68
|
+
"stop" -> {
|
|
69
|
+
val name = args.opt(0) as? String
|
|
70
|
+
if (name !in kinds) failure("INVALID_ARGUMENT", "Unknown motion sensor")
|
|
71
|
+
else CraftNativeCapabilityResult(data = stop(name!!))
|
|
72
|
+
}
|
|
73
|
+
"stopAll" -> { stopAll(); CraftNativeCapabilityResult(data = true) }
|
|
74
|
+
else -> failure("UNKNOWN_ACTION", "Unsupported MotionSensors method")
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
private fun start(options: JSONObject?): CraftNativeCapabilityResult {
|
|
79
|
+
val name = options?.opt("sensor") as? String
|
|
80
|
+
val kind = kinds[name] ?: return failure("INVALID_ARGUMENT", "Unknown motion sensor")
|
|
81
|
+
val rawInterval = if (options?.has("intervalMs") == true) options.opt("intervalMs") else 100
|
|
82
|
+
val interval = (rawInterval as? Number)?.toDouble() ?: Double.NaN
|
|
83
|
+
// Do not silently opt an app into Android's high sampling-rate permission.
|
|
84
|
+
if (!interval.isFinite() || interval < 5 || interval > 60_000 || interval % 1 != 0.0) {
|
|
85
|
+
return failure("INVALID_ARGUMENT", "intervalMs must be an integer from 5 through 60000")
|
|
86
|
+
}
|
|
87
|
+
if (!source.available(kind.type)) return failure("UNAVAILABLE", "Sensor is not present: $name")
|
|
88
|
+
val stream = Stream()
|
|
89
|
+
val registration = try {
|
|
90
|
+
source.register(kind.type, (interval * 1000).toInt()) { values, timestamp, accuracy ->
|
|
91
|
+
// Android can still deliver a queued sample after unregisterListener.
|
|
92
|
+
if (!closed && streams[name] === stream && values.size >= 3) {
|
|
93
|
+
val reading = if (kind.type == Sensor.TYPE_ROTATION_VECTOR) {
|
|
94
|
+
val quaternion = FloatArray(4)
|
|
95
|
+
SensorManager.getQuaternionFromVector(quaternion, values)
|
|
96
|
+
JSONObject().put("w", quaternion[0]).put("x", quaternion[1])
|
|
97
|
+
.put("y", quaternion[2]).put("z", quaternion[3])
|
|
98
|
+
} else JSONObject().put("x", values[0]).put("y", values[1]).put("z", values[2])
|
|
99
|
+
emit(JSONObject().put("sensor", name).put("values", reading).put("unit", kind.unit)
|
|
100
|
+
.put("timestampMs", timestamp / 1_000_000.0).put("accuracy", accuracy))
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
} catch (error: Exception) {
|
|
104
|
+
return failure("SENSOR_ERROR", error.message ?: "Sensor registration failed")
|
|
105
|
+
} ?: return failure("UNAVAILABLE", "Sensor registration was rejected: $name")
|
|
106
|
+
stream.registration = registration
|
|
107
|
+
// Validate and acquire the replacement before retiring a working stream.
|
|
108
|
+
val previous = streams.put(name!!, stream)
|
|
109
|
+
previous?.registration?.close()
|
|
110
|
+
return CraftNativeCapabilityResult(data = true)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
private fun failure(code: String, message: String) = CraftNativeCapabilityResult(code = code, message = message)
|
|
114
|
+
private fun stop(name: String): Boolean {
|
|
115
|
+
val stream = streams.remove(name) ?: return false
|
|
116
|
+
stream.registration?.close()
|
|
117
|
+
return true
|
|
118
|
+
}
|
|
119
|
+
fun stopAll() { streams.keys.toList().forEach(::stop) }
|
|
120
|
+
fun close() { closed = true; stopAll(); emit = {} }
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** A pressure-only stream with a new reference on each successful start. */
|
|
124
|
+
internal class CraftNativeBarometer(private val source: CraftMotionSource) {
|
|
125
|
+
private class Stream {
|
|
126
|
+
var registration: CraftMotionRegistration? = null
|
|
127
|
+
var reference: Float? = null
|
|
128
|
+
}
|
|
129
|
+
private var stream: Stream? = null
|
|
130
|
+
private var closed = false
|
|
131
|
+
var emit: (JSONObject) -> Unit = {}
|
|
132
|
+
|
|
133
|
+
fun perform(method: String, args: JSONArray): CraftNativeCapabilityResult {
|
|
134
|
+
if (closed) return failure("HOST_CLOSED", "Barometer host is closed")
|
|
135
|
+
return when (method) {
|
|
136
|
+
"getAvailability" -> CraftNativeCapabilityResult(data = JSONObject()
|
|
137
|
+
.put("relativeAltitude", source.available(Sensor.TYPE_PRESSURE))
|
|
138
|
+
.put("absoluteAltitude", false).put("background", false))
|
|
139
|
+
"start" -> start(args.optJSONObject(0), args.length() != 0)
|
|
140
|
+
"stop" -> CraftNativeCapabilityResult(data = stop("requested"))
|
|
141
|
+
else -> failure("UNKNOWN_ACTION", "Unsupported Barometer method")
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
private fun start(options: JSONObject?, supplied: Boolean): CraftNativeCapabilityResult {
|
|
146
|
+
if (supplied && options == null) return failure("INVALID_ARGUMENT", "Barometer.start needs an options object")
|
|
147
|
+
if (options?.has("background") == true && options.opt("background") !is Boolean)
|
|
148
|
+
return failure("INVALID_ARGUMENT", "background must be boolean")
|
|
149
|
+
if (options?.optBoolean("background", false) == true)
|
|
150
|
+
return failure("UNSUPPORTED", "Background barometer recording is not supported")
|
|
151
|
+
val raw = if (options?.has("intervalMs") == true) options.opt("intervalMs") else 100
|
|
152
|
+
val interval = (raw as? Number)?.toDouble() ?: Double.NaN
|
|
153
|
+
if (!interval.isFinite() || interval < 5 || interval > 60_000 || interval % 1 != 0.0)
|
|
154
|
+
return failure("INVALID_ARGUMENT", "intervalMs must be an integer from 5 through 60000")
|
|
155
|
+
if (!source.available(Sensor.TYPE_PRESSURE)) return failure("UNAVAILABLE", "Barometer is not present")
|
|
156
|
+
val next = Stream()
|
|
157
|
+
val registration = try {
|
|
158
|
+
source.register(Sensor.TYPE_PRESSURE, (interval * 1000).toInt()) { values, timestamp, _ ->
|
|
159
|
+
if (!closed && stream === next) {
|
|
160
|
+
val pressure = values.firstOrNull()
|
|
161
|
+
if (pressure == null || !pressure.isFinite() || pressure <= 0) {
|
|
162
|
+
stop("sensorError")
|
|
163
|
+
} else {
|
|
164
|
+
val reference = next.reference ?: pressure.also { next.reference = it }
|
|
165
|
+
val altitude = SensorManager.getAltitude(reference, pressure)
|
|
166
|
+
if (!altitude.isFinite()) stop("sensorError")
|
|
167
|
+
else emit(JSONObject().put("state", "sample").put("pressureHpa", pressure)
|
|
168
|
+
.put("relativeAltitudeMeters", altitude).put("referencePressureHpa", reference)
|
|
169
|
+
.put("timestampMs", timestamp / 1_000_000.0))
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
} catch (error: Exception) {
|
|
174
|
+
return failure("SENSOR_ERROR", error.message ?: "Barometer registration failed")
|
|
175
|
+
} ?: return failure("UNAVAILABLE", "Barometer registration was rejected")
|
|
176
|
+
next.registration = registration
|
|
177
|
+
val previous = stream
|
|
178
|
+
stream = next
|
|
179
|
+
previous?.registration?.close()
|
|
180
|
+
return CraftNativeCapabilityResult(data = true)
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
fun stop(reason: String): Boolean {
|
|
184
|
+
val previous = stream ?: return false
|
|
185
|
+
stream = null
|
|
186
|
+
previous.registration?.close()
|
|
187
|
+
emit(JSONObject().put("state", "stopped").put("reason", reason))
|
|
188
|
+
return true
|
|
189
|
+
}
|
|
190
|
+
fun close() { closed = true; stop("disposed"); emit = {} }
|
|
191
|
+
private fun failure(code: String, message: String) = CraftNativeCapabilityResult(code = code, message = message)
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Android counter and detector semantics stay distinct from interval totals. */
|
|
195
|
+
internal class CraftNativePedometer(private val source: CraftMotionSource, private val hasPermission: () -> Boolean) {
|
|
196
|
+
private class Stream { var registration: CraftMotionRegistration? = null }
|
|
197
|
+
private var stream: Stream? = null
|
|
198
|
+
private var closed = false
|
|
199
|
+
var emit: (JSONObject) -> Unit = {}
|
|
200
|
+
|
|
201
|
+
fun perform(method: String, args: JSONArray): CraftNativeCapabilityResult {
|
|
202
|
+
if (closed) return failure("HOST_CLOSED", "Pedometer host is closed")
|
|
203
|
+
return when (method) {
|
|
204
|
+
"getAvailability" -> CraftNativeCapabilityResult(data = JSONObject()
|
|
205
|
+
.put("counter", source.available(Sensor.TYPE_STEP_COUNTER))
|
|
206
|
+
.put("detector", source.available(Sensor.TYPE_STEP_DETECTOR))
|
|
207
|
+
.put("history", false).put("background", false).put("cadence", false).put("floors", false))
|
|
208
|
+
"getPermissionStatus" -> CraftNativeCapabilityResult(data = if (hasPermission()) "granted" else "denied")
|
|
209
|
+
"start" -> start(args.optJSONObject(0), args.length() != 0)
|
|
210
|
+
"stop" -> CraftNativeCapabilityResult(data = stop("requested"))
|
|
211
|
+
"query" -> failure("UNSUPPORTED", "Android's step sensor does not provide interval history")
|
|
212
|
+
else -> failure("UNKNOWN_ACTION", "Unsupported Pedometer method")
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
private fun start(options: JSONObject?, supplied: Boolean): CraftNativeCapabilityResult {
|
|
217
|
+
if (supplied && options == null) return failure("INVALID_ARGUMENT", "Pedometer.start needs an options object")
|
|
218
|
+
if (options?.has("background") == true && options.opt("background") !is Boolean)
|
|
219
|
+
return failure("INVALID_ARGUMENT", "background must be boolean")
|
|
220
|
+
if (options?.optBoolean("background", false) == true)
|
|
221
|
+
return failure("UNSUPPORTED", "Background step streaming is not supported by this sensor adapter")
|
|
222
|
+
val name = if (options?.has("sensor") == true) options.opt("sensor") as? String else "counter"
|
|
223
|
+
val type = when (name) {
|
|
224
|
+
"counter" -> Sensor.TYPE_STEP_COUNTER
|
|
225
|
+
"detector" -> Sensor.TYPE_STEP_DETECTOR
|
|
226
|
+
else -> return failure("INVALID_ARGUMENT", "sensor must be counter or detector")
|
|
227
|
+
}
|
|
228
|
+
if (options?.has("startDate") == true || options?.has("endDate") == true)
|
|
229
|
+
return failure("UNSUPPORTED", "Android steps are not counts for a requested interval")
|
|
230
|
+
if (!source.available(type)) return failure("UNAVAILABLE", "Requested step sensor is not present")
|
|
231
|
+
if (!hasPermission()) return failure("PERMISSION_DENIED", "Activity recognition permission is required")
|
|
232
|
+
val next = Stream()
|
|
233
|
+
val registration = try {
|
|
234
|
+
source.register(type, 200_000) { values, timestamp, _ ->
|
|
235
|
+
if (!closed && stream === next) {
|
|
236
|
+
val value = values.firstOrNull()
|
|
237
|
+
if (!hasPermission()) stop("permissionDenied")
|
|
238
|
+
else if (value == null || !value.isFinite() || value < 0 || value % 1f != 0f || (name == "detector" && value != 1f)) stop("sensorError")
|
|
239
|
+
else {
|
|
240
|
+
val payload = JSONObject().put("state", "sample").put("kind", if (name == "counter") "counter" else "step")
|
|
241
|
+
.put("timestampMs", timestamp / 1_000_000.0)
|
|
242
|
+
if (name == "counter") payload.put("stepsSinceBoot", value.toDouble())
|
|
243
|
+
else payload.put("count", 1)
|
|
244
|
+
emit(payload)
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
} catch (error: SecurityException) {
|
|
249
|
+
return failure("PERMISSION_DENIED", "Activity recognition permission is required")
|
|
250
|
+
} catch (error: Exception) {
|
|
251
|
+
return failure("SENSOR_ERROR", error.message ?: "Step sensor registration failed")
|
|
252
|
+
} ?: return failure("UNAVAILABLE", "Step sensor registration was rejected")
|
|
253
|
+
next.registration = registration
|
|
254
|
+
val previous = stream
|
|
255
|
+
stream = next
|
|
256
|
+
previous?.registration?.close()
|
|
257
|
+
return CraftNativeCapabilityResult(data = true)
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
fun stop(reason: String): Boolean {
|
|
261
|
+
val previous = stream ?: return false
|
|
262
|
+
stream = null
|
|
263
|
+
previous.registration?.close()
|
|
264
|
+
emit(JSONObject().put("state", "stopped").put("reason", reason))
|
|
265
|
+
return true
|
|
266
|
+
}
|
|
267
|
+
fun close() { closed = true; stop("disposed"); emit = {} }
|
|
268
|
+
private fun failure(code: String, message: String) = CraftNativeCapabilityResult(code = code, message = message)
|
|
269
|
+
}
|
|
@@ -129,10 +129,12 @@ internal class CraftNativeServices(
|
|
|
129
129
|
add("healthAvailability")
|
|
130
130
|
}
|
|
131
131
|
if (healthServices != null) add("healthServices")
|
|
132
|
+
if (healthServices != null && config.optBoolean("enablePedometer")) add("pedometerPassive")
|
|
132
133
|
if (config.optBoolean("enableWallet")) add("wallet")
|
|
133
134
|
if (config.optBoolean("enableKeepAwake")) add("keepAwake")
|
|
134
135
|
if (config.optBoolean("enableGeolocation") || config.optBoolean("enableBackgroundLocation")) {
|
|
135
136
|
add("geolocation")
|
|
137
|
+
if (config.optBoolean("enableBarometer") && config.optBoolean("enableBackgroundLocation")) add("locationRecordingBarometer")
|
|
136
138
|
if (CraftNative.supportsRecordingOwnership()) add("locationRecordingOwnership")
|
|
137
139
|
if (CraftNative.supportsRecordingIntentIdentity()) add("locationRecordingIntentIdentity")
|
|
138
140
|
if (CraftNative.supportsRecordingPowerMode()) add("locationRecordingPowerMode")
|
|
@@ -146,7 +148,7 @@ internal class CraftNativeServices(
|
|
|
146
148
|
}
|
|
147
149
|
|
|
148
150
|
fun handles(module: String): Boolean = module in setOf(
|
|
149
|
-
"Dialog", "Browser", "Linking", "Share", "Push", "Health", "KeepAwake", "Brightness", "StatusBar", "Speech", "Background",
|
|
151
|
+
"Dialog", "Browser", "Linking", "Share", "Push", "Health", "PedometerPassive", "KeepAwake", "Brightness", "StatusBar", "Speech", "Background",
|
|
150
152
|
"Location", "Media", "CrashReporter", "HeartRate", "AudioRecording",
|
|
151
153
|
"Wallet",
|
|
152
154
|
)
|
|
@@ -165,6 +167,9 @@ internal class CraftNativeServices(
|
|
|
165
167
|
"Health" -> if (healthServices != null && method in setOf("startWorkout", "pauseWorkout", "resumeWorkout", "endWorkout")) {
|
|
166
168
|
healthServices.perform(method, args, done)
|
|
167
169
|
} else health?.perform(method, args, done) ?: done(failure("CAPABILITY_DISABLED", "Health is not enabled for this app"))
|
|
170
|
+
"PedometerPassive" -> if (config.optBoolean("enablePedometer") && healthServices != null) {
|
|
171
|
+
healthServices.perform("passive:$method", args, done)
|
|
172
|
+
} else done(failure("CAPABILITY_DISABLED", "Passive steps require pedometer and Health Services"))
|
|
168
173
|
"Wallet" -> wallet(method, args, done)
|
|
169
174
|
"KeepAwake" -> keepAwake(args.optBoolean(0, true), done)
|
|
170
175
|
"Brightness" -> brightness(method, args, done)
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
package {{PACKAGE_NAME}}
|
|
2
|
+
|
|
3
|
+
import android.Manifest
|
|
4
|
+
import android.content.Context
|
|
5
|
+
import android.content.BroadcastReceiver
|
|
6
|
+
import android.content.Intent
|
|
7
|
+
import androidx.work.Worker
|
|
8
|
+
import androidx.work.WorkerParameters
|
|
9
|
+
import androidx.work.WorkManager
|
|
10
|
+
import androidx.work.OneTimeWorkRequestBuilder
|
|
11
|
+
import androidx.work.ExistingWorkPolicy
|
|
12
|
+
import android.content.pm.PackageManager
|
|
13
|
+
import android.os.SystemClock
|
|
14
|
+
import androidx.core.content.ContextCompat
|
|
15
|
+
import androidx.health.services.client.HealthServices
|
|
16
|
+
import androidx.health.services.client.PassiveListenerService
|
|
17
|
+
import androidx.health.services.client.data.DataPointContainer
|
|
18
|
+
import androidx.health.services.client.data.DataType
|
|
19
|
+
import androidx.health.services.client.data.PassiveListenerConfig
|
|
20
|
+
import org.json.JSONArray
|
|
21
|
+
import org.json.JSONObject
|
|
22
|
+
import java.time.Instant
|
|
23
|
+
import java.time.ZoneId
|
|
24
|
+
import java.util.concurrent.TimeUnit
|
|
25
|
+
|
|
26
|
+
/** Daily Health Services totals are not the raw counter since boot. */
|
|
27
|
+
internal class CraftPassiveStepStore(context: Context) {
|
|
28
|
+
private val prefs = context.getSharedPreferences("craft-passive-steps", Context.MODE_PRIVATE)
|
|
29
|
+
companion object { private val lock = Any() }
|
|
30
|
+
fun enabled(): Boolean = synchronized(lock) { prefs.getBoolean("enabled", false) }
|
|
31
|
+
fun setEnabled(value: Boolean): Boolean = synchronized(lock) { prefs.edit().putBoolean("enabled", value).commit() }
|
|
32
|
+
fun history(): JSONArray = synchronized(lock) { JSONArray(prefs.getString("days", "[]")) }
|
|
33
|
+
fun save(steps: Long, start: Long, end: Long, zone: String): Boolean = synchronized(lock) {
|
|
34
|
+
if (!enabled() || steps < 0 || start > end) return@synchronized false
|
|
35
|
+
// Attribute a completed interval ending at midnight to its start day.
|
|
36
|
+
val day = Instant.ofEpochMilli(start).atZone(ZoneId.of(zone)).toLocalDate().toString()
|
|
37
|
+
val rows = history()
|
|
38
|
+
val values = (0 until rows.length()).map { rows.getJSONObject(it) }.toMutableList()
|
|
39
|
+
val previous = values.firstOrNull { it.optString("day") == day && it.optString("timeZone") == zone }
|
|
40
|
+
if (previous != null && previous.optLong("endDate") > end) return@synchronized false
|
|
41
|
+
values.removeAll { it.optString("day") == day && it.optString("timeZone") == zone }
|
|
42
|
+
values.add(JSONObject().put("kind", "daily").put("source", "healthServices").put("day", day).put("timeZone", zone)
|
|
43
|
+
.put("startDate", start).put("endDate", end).put("steps", steps))
|
|
44
|
+
val retained = JSONArray(values.sortedBy { it.getLong("endDate") }.takeLast(8))
|
|
45
|
+
prefs.edit().putString("days", retained.toString()).commit()
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Health Services binds this service even when no Craft screen is alive. */
|
|
50
|
+
class CraftPassiveStepsService : PassiveListenerService() {
|
|
51
|
+
override fun onNewDataPointsReceived(dataPoints: DataPointContainer) {
|
|
52
|
+
val storage = CraftPassiveStepStore(this)
|
|
53
|
+
val boot = Instant.ofEpochMilli(System.currentTimeMillis() - SystemClock.elapsedRealtime())
|
|
54
|
+
for (point in dataPoints.getData(DataType.STEPS_DAILY)) {
|
|
55
|
+
storage.save(point.value, point.getStartInstant(boot).toEpochMilli(), point.getEndInstant(boot).toEpochMilli(), ZoneId.systemDefault().id)
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
override fun onPermissionLost() { CraftPassiveStepStore(this).setEnabled(false) }
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
internal interface CraftPassiveStepProvider {
|
|
62
|
+
fun supported(): Boolean
|
|
63
|
+
fun start()
|
|
64
|
+
fun stop()
|
|
65
|
+
fun discardRegistration() { stop() }
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
private class CraftHealthPassiveProvider(context: Context) : CraftPassiveStepProvider {
|
|
69
|
+
private val client = HealthServices.getClient(context).passiveMonitoringClient
|
|
70
|
+
override fun supported(): Boolean = DataType.STEPS_DAILY in client.getCapabilitiesAsync().get(10, TimeUnit.SECONDS).supportedDataTypesPassiveMonitoring
|
|
71
|
+
override fun start() {
|
|
72
|
+
client.setPassiveListenerServiceAsync(CraftPassiveStepsService::class.java,
|
|
73
|
+
PassiveListenerConfig.builder().setDataTypes(setOf(DataType.STEPS_DAILY)).build()).get(10, TimeUnit.SECONDS)
|
|
74
|
+
}
|
|
75
|
+
override fun stop() { client.clearPassiveListenerServiceAsync().get(10, TimeUnit.SECONDS) }
|
|
76
|
+
override fun discardRegistration() { client.clearPassiveListenerServiceAsync() }
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
internal class CraftPassiveSteps(
|
|
80
|
+
private val context: Context,
|
|
81
|
+
private val provider: CraftPassiveStepProvider = CraftHealthPassiveProvider(context),
|
|
82
|
+
private val permission: () -> Boolean = { ContextCompat.checkSelfPermission(context, Manifest.permission.ACTIVITY_RECOGNITION) == PackageManager.PERMISSION_GRANTED },
|
|
83
|
+
) {
|
|
84
|
+
private val storage = CraftPassiveStepStore(context)
|
|
85
|
+
private fun failure(code: String, message: String) = CraftNativeCapabilityResult(code = code, message = message)
|
|
86
|
+
|
|
87
|
+
fun perform(method: String, done: (CraftNativeCapabilityResult) -> Unit) {
|
|
88
|
+
// These bounded provider calls run on a worker: no main-thread binder wait.
|
|
89
|
+
executor.execute {
|
|
90
|
+
val result = try {
|
|
91
|
+
when (method) {
|
|
92
|
+
"history" -> CraftNativeCapabilityResult(data = storage.history())
|
|
93
|
+
"availability" -> CraftNativeCapabilityResult(data = JSONObject()
|
|
94
|
+
.put("supported", provider.supported())
|
|
95
|
+
.put("enabled", storage.enabled()).put("permissionGranted", permission()))
|
|
96
|
+
"start", "restore" -> {
|
|
97
|
+
if (method == "restore" && !storage.enabled()) CraftNativeCapabilityResult(data = false)
|
|
98
|
+
else if (!permission()) failure("PERMISSION_DENIED", "Activity recognition access is required")
|
|
99
|
+
else if (!provider.supported())
|
|
100
|
+
failure("UNAVAILABLE", "This device has no passive daily steps provider")
|
|
101
|
+
else {
|
|
102
|
+
// Persist intent before binding; the service may receive data immediately.
|
|
103
|
+
if (!storage.setEnabled(true)) failure("STORAGE_ERROR", "Passive registration intent could not be saved")
|
|
104
|
+
else {
|
|
105
|
+
try {
|
|
106
|
+
provider.start()
|
|
107
|
+
CraftNativeCapabilityResult(data = true)
|
|
108
|
+
} catch (error: Exception) {
|
|
109
|
+
if (method == "start") storage.setEnabled(false)
|
|
110
|
+
runCatching { provider.discardRegistration() }
|
|
111
|
+
throw error
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
"stop" -> {
|
|
117
|
+
if (!storage.setEnabled(false)) failure("STORAGE_ERROR", "Passive registration intent could not be cleared")
|
|
118
|
+
else {
|
|
119
|
+
provider.stop()
|
|
120
|
+
CraftNativeCapabilityResult(data = true)
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
else -> failure("UNKNOWN_ACTION", "Unknown passive pedometer action")
|
|
124
|
+
}
|
|
125
|
+
} catch (error: Exception) {
|
|
126
|
+
failure("UNAVAILABLE", error.cause?.message ?: error.message ?: "Health Services is unavailable")
|
|
127
|
+
}
|
|
128
|
+
ContextCompat.getMainExecutor(context).execute { done(result) }
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
companion object {
|
|
132
|
+
// Serialize app-wide start/stop commands, including commands from different routes.
|
|
133
|
+
private val executor = java.util.concurrent.Executors.newSingleThreadExecutor()
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Registrations do not survive a reboot. Restore only explicit saved opt-in. */
|
|
138
|
+
class CraftPassiveStepsBootReceiver : BroadcastReceiver() {
|
|
139
|
+
override fun onReceive(context: Context, intent: Intent) {
|
|
140
|
+
if (intent.action != Intent.ACTION_BOOT_COMPLETED || !CraftPassiveStepStore(context).enabled()) return
|
|
141
|
+
WorkManager.getInstance(context).enqueueUniqueWork("craft-passive-steps-restore", ExistingWorkPolicy.KEEP,
|
|
142
|
+
OneTimeWorkRequestBuilder<CraftPassiveStepsRestoreWorker>().build())
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
class CraftPassiveStepsRestoreWorker(context: Context, params: WorkerParameters) : Worker(context, params) {
|
|
147
|
+
override fun doWork(): Result {
|
|
148
|
+
if (!CraftPassiveStepStore(applicationContext).enabled()) return Result.success()
|
|
149
|
+
val finished = java.util.concurrent.CountDownLatch(1)
|
|
150
|
+
var answer: CraftNativeCapabilityResult? = null
|
|
151
|
+
CraftPassiveSteps(applicationContext).perform("restore") { answer = it; finished.countDown() }
|
|
152
|
+
if (!finished.await(25, TimeUnit.SECONDS)) return Result.retry()
|
|
153
|
+
return when (answer?.code) {
|
|
154
|
+
null -> Result.success()
|
|
155
|
+
"PERMISSION_DENIED" -> { CraftPassiveStepStore(applicationContext).setEnabled(false); Result.failure() }
|
|
156
|
+
else -> if (runAttemptCount < 3) Result.retry() else Result.failure()
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
package com.craft.runtime
|
|
2
|
+
|
|
3
|
+
import android.content.Context
|
|
4
|
+
import android.hardware.Sensor
|
|
5
|
+
import android.hardware.SensorEvent
|
|
6
|
+
import android.hardware.SensorEventListener
|
|
7
|
+
import android.hardware.SensorManager
|
|
8
|
+
import android.os.Handler
|
|
9
|
+
import android.os.Looper
|
|
10
|
+
import android.os.PowerManager
|
|
11
|
+
import org.json.JSONArray
|
|
12
|
+
import org.json.JSONObject
|
|
13
|
+
import java.io.File
|
|
14
|
+
import java.util.UUID
|
|
15
|
+
|
|
16
|
+
/** Native-only journal: JS suspension cannot drop pressure readings from a route. */
|
|
17
|
+
object CraftRecordingPressure {
|
|
18
|
+
private fun file(context: Context, id: String): File {
|
|
19
|
+
val digest = java.security.MessageDigest.getInstance("SHA-256").digest(id.toByteArray(Charsets.UTF_8))
|
|
20
|
+
return File(context.filesDir, "craft-pressure-${digest.joinToString("") { "%02x".format(it) }}.jsonl")
|
|
21
|
+
}
|
|
22
|
+
fun requireAvailable(context: Context) {
|
|
23
|
+
val config = context.assets.open("craft.config.json").bufferedReader().use { JSONObject(it.readText()) }
|
|
24
|
+
if (!config.optBoolean("enableBarometer") || !config.optBoolean("enableBackgroundLocation"))
|
|
25
|
+
throw CraftRecordingException("CAPABILITY_DISABLED", "Recording pressure requires enableBarometer and enableBackgroundLocation")
|
|
26
|
+
val manager = context.getSystemService(SensorManager::class.java)
|
|
27
|
+
if (manager?.getDefaultSensor(Sensor.TYPE_PRESSURE, true) == null && manager?.getDefaultSensor(Sensor.TYPE_PRESSURE) == null)
|
|
28
|
+
throw CraftRecordingException("UNAVAILABLE", "Barometer is not present")
|
|
29
|
+
}
|
|
30
|
+
// Callers also hold the recording-store lock to serialize owner/ACK changes.
|
|
31
|
+
fun append(context: Context, id: String, event: JSONObject) {
|
|
32
|
+
val bytes = (event.put("recordingId", id).toString() + "\n").toByteArray(Charsets.UTF_8)
|
|
33
|
+
java.io.FileOutputStream(file(context, id), true).use { it.write(bytes); it.fd.sync() }
|
|
34
|
+
}
|
|
35
|
+
fun read(context: Context, id: String): JSONArray {
|
|
36
|
+
val result = JSONArray()
|
|
37
|
+
val source = file(context, id)
|
|
38
|
+
if (id.isEmpty() || !source.exists()) return result
|
|
39
|
+
source.forEachLine { line ->
|
|
40
|
+
if (line.isNotBlank()) {
|
|
41
|
+
val event = JSONObject(line)
|
|
42
|
+
check(event.getString("recordingId") == id) { "Invalid pressure recording owner" }
|
|
43
|
+
result.put(event)
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return result
|
|
47
|
+
}
|
|
48
|
+
fun delete(context: Context, id: String) {
|
|
49
|
+
val source = file(context, id)
|
|
50
|
+
if (source.exists() && !source.delete()) throw CraftRecordingException("RECORDING_STORAGE_ERROR", "Unable to remove acknowledged pressure recording")
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
internal interface CraftRecordingPressureSource {
|
|
55
|
+
fun open(sample: (Float, Long) -> Unit): AutoCloseable
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
private class AndroidRecordingPressureSource(private val context: Context) : CraftRecordingPressureSource {
|
|
59
|
+
override fun open(sample: (Float, Long) -> Unit): AutoCloseable {
|
|
60
|
+
CraftRecordingPressure.requireAvailable(context)
|
|
61
|
+
val manager = context.getSystemService(SensorManager::class.java)
|
|
62
|
+
?: throw IllegalStateException("Sensor manager unavailable")
|
|
63
|
+
val sensor = manager.getDefaultSensor(Sensor.TYPE_PRESSURE, true)
|
|
64
|
+
?: manager.getDefaultSensor(Sensor.TYPE_PRESSURE)
|
|
65
|
+
?: throw IllegalStateException("Barometer unavailable")
|
|
66
|
+
// A non-wake-up sensor cannot deliver continuously during CPU suspend.
|
|
67
|
+
// Only the explicitly requested, unpaused foreground recording owns this lock.
|
|
68
|
+
val wake = if (!sensor.isWakeUpSensor) context.getSystemService(PowerManager::class.java)
|
|
69
|
+
.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "craft:recording-pressure").apply { setReferenceCounted(false) } else null
|
|
70
|
+
val listener = object : SensorEventListener {
|
|
71
|
+
override fun onSensorChanged(event: SensorEvent) { sample(event.values.firstOrNull() ?: Float.NaN, event.timestamp) }
|
|
72
|
+
override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) {}
|
|
73
|
+
}
|
|
74
|
+
try {
|
|
75
|
+
wake?.acquire()
|
|
76
|
+
check(manager.registerListener(listener, sensor, 1_000_000, Handler(Looper.getMainLooper()))) { "Pressure registration rejected" }
|
|
77
|
+
} catch (error: Exception) {
|
|
78
|
+
manager.unregisterListener(listener)
|
|
79
|
+
if (wake?.isHeld == true) wake.release()
|
|
80
|
+
throw error
|
|
81
|
+
}
|
|
82
|
+
return AutoCloseable {
|
|
83
|
+
try { manager.unregisterListener(listener) }
|
|
84
|
+
finally { if (wake?.isHeld == true) wake.release() }
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** One service-owned segment, independent of native screen and WebView lifetimes. */
|
|
90
|
+
internal class CraftRecordingPressureSession(
|
|
91
|
+
private val context: Context,
|
|
92
|
+
val id: String,
|
|
93
|
+
private val source: CraftRecordingPressureSource = AndroidRecordingPressureSource(context),
|
|
94
|
+
) {
|
|
95
|
+
val epoch = CraftLocationRecordingStore.pressureEpoch
|
|
96
|
+
private var registration: AutoCloseable? = null
|
|
97
|
+
private var closed = false
|
|
98
|
+
private var started = false
|
|
99
|
+
private var reference: Float? = null
|
|
100
|
+
private var lastTimestamp: Long? = null
|
|
101
|
+
private var segment = UUID.randomUUID().toString()
|
|
102
|
+
|
|
103
|
+
private fun ownsRecording() = epoch == CraftLocationRecordingStore.pressureEpoch && CraftLocationRecordingStore.recordingId(context) == id &&
|
|
104
|
+
CraftLocationRecordingStore.isActive(context) && !CraftLocationRecordingStore.isPaused(context) &&
|
|
105
|
+
CraftLocationRecordingStore.pressureEnabled(context)
|
|
106
|
+
|
|
107
|
+
fun start() = synchronized(CraftLocationRecordingStore) {
|
|
108
|
+
if (closed || started || !ownsRecording()) return@synchronized
|
|
109
|
+
started = true
|
|
110
|
+
try {
|
|
111
|
+
write(JSONObject().put("state", "segment").put("reason", "startedOrRecovered"))
|
|
112
|
+
val acquired = source.open(::sample)
|
|
113
|
+
if (closed) acquired.close() else registration = acquired
|
|
114
|
+
} catch (error: Exception) { fail(error) }
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
private fun write(event: JSONObject) {
|
|
118
|
+
CraftRecordingPressure.append(context, id, event.put("segmentId", segment).put("timestamp", System.currentTimeMillis()))
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
private fun sample(pressure: Float, timestamp: Long) = synchronized(CraftLocationRecordingStore) {
|
|
122
|
+
if (closed || !ownsRecording()) return@synchronized
|
|
123
|
+
try {
|
|
124
|
+
require(pressure.isFinite() && pressure > 0 && timestamp >= 0) { "Invalid pressure sample" }
|
|
125
|
+
val previous = lastTimestamp
|
|
126
|
+
if (previous != null && timestamp <= previous) return@synchronized
|
|
127
|
+
if (previous != null && timestamp - previous < 1_000_000_000L) return@synchronized
|
|
128
|
+
if (previous != null && timestamp - previous > 3_000_000_000L) {
|
|
129
|
+
segment = UUID.randomUUID().toString()
|
|
130
|
+
reference = null
|
|
131
|
+
write(JSONObject().put("state", "segment").put("reason", "sampleGap").put("timestampMs", timestamp / 1_000_000.0))
|
|
132
|
+
}
|
|
133
|
+
val baseline = reference ?: pressure.also { reference = it }
|
|
134
|
+
val altitude = SensorManager.getAltitude(baseline, pressure)
|
|
135
|
+
require(altitude.isFinite()) { "Invalid pressure altitude" }
|
|
136
|
+
write(JSONObject().put("state", "sample").put("pressureHpa", pressure)
|
|
137
|
+
.put("referencePressureHpa", baseline).put("relativeAltitudeMeters", altitude)
|
|
138
|
+
.put("timestampMs", timestamp / 1_000_000.0))
|
|
139
|
+
lastTimestamp = timestamp
|
|
140
|
+
} catch (error: Exception) { fail(error) }
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
private fun fail(error: Exception) {
|
|
144
|
+
close()
|
|
145
|
+
runCatching { write(JSONObject().put("state", "stopped").put("reason", "sensorOrStorageError")) }
|
|
146
|
+
CraftLocationRecordingStore.failPressure(context, id, error.message ?: "Pressure recording failed")
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
fun close() {
|
|
150
|
+
if (closed) return
|
|
151
|
+
closed = true
|
|
152
|
+
runCatching { registration?.close() }
|
|
153
|
+
.onFailure { android.util.Log.e("CraftLocation", "Unable to unregister pressure listener", it) }
|
|
154
|
+
registration = null
|
|
155
|
+
}
|
|
156
|
+
}
|