craft-native 0.0.167 → 0.0.169
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/dist/android/src/index.d.ts +62 -0
- package/dist/android/src/index.js +138 -18
- package/dist/android/templates/CraftNativeBackground.kt.template +296 -0
- package/dist/android/templates/CraftNativeCapabilities.kt.template +59 -37
- package/dist/android/templates/CraftNativeHealth.kt.template +1046 -0
- package/dist/android/templates/CraftNativeHealthStub.kt.template +32 -0
- package/dist/android/templates/CraftNativeMessagingService.kt.template +75 -0
- package/dist/android/templates/CraftNativeMutation.kt.template +14 -2
- package/dist/android/templates/CraftNativeServices.kt.template +561 -0
- package/dist/android/templates/CraftNativeShell.kt.template +1013 -0
- package/dist/android/templates/LocationRecordingService.kt.template +7 -0
- package/dist/android/templates/MainActivityNative.kt.template +738 -132
- package/dist/android/templates/build.gradle.kts.app.template +16 -0
- package/dist/cli.js +1 -1
- package/dist/index.cjs +1 -1
- package/dist/index.js +1 -1
- package/dist/ios/templates/CraftNativeScreen.swift +22 -9
- package/package.json +1 -1
|
@@ -0,0 +1,561 @@
|
|
|
1
|
+
package {{PACKAGE_NAME}}
|
|
2
|
+
|
|
3
|
+
import android.Manifest
|
|
4
|
+
import android.app.Activity
|
|
5
|
+
import android.content.ActivityNotFoundException
|
|
6
|
+
import android.content.Context
|
|
7
|
+
import android.content.Intent
|
|
8
|
+
import android.content.pm.PackageManager
|
|
9
|
+
import android.net.Uri
|
|
10
|
+
import android.os.Build
|
|
11
|
+
import android.os.Handler
|
|
12
|
+
import android.os.Looper
|
|
13
|
+
import android.provider.Settings
|
|
14
|
+
import android.speech.tts.TextToSpeech
|
|
15
|
+
import android.speech.tts.UtteranceProgressListener
|
|
16
|
+
import android.util.Log
|
|
17
|
+
import android.view.WindowManager
|
|
18
|
+
import androidx.appcompat.app.AlertDialog
|
|
19
|
+
import androidx.browser.customtabs.CustomTabsIntent
|
|
20
|
+
import androidx.core.app.ActivityCompat
|
|
21
|
+
import androidx.core.app.NotificationManagerCompat
|
|
22
|
+
import androidx.core.content.ContextCompat
|
|
23
|
+
import androidx.core.content.FileProvider
|
|
24
|
+
import androidx.core.view.WindowCompat
|
|
25
|
+
import org.json.JSONArray
|
|
26
|
+
import org.json.JSONObject
|
|
27
|
+
import java.io.File
|
|
28
|
+
import java.util.Locale
|
|
29
|
+
import java.util.UUID
|
|
30
|
+
{{NATIVE_PUSH_IMPORTS}}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The capabilities a native screen asks for beyond Craft's core set
|
|
34
|
+
* (CraftNativeCapabilities): the system's dialogs, the in-app browser, the
|
|
35
|
+
* share sheet, other apps (`Linking`), push, Health Connect (`Health`,
|
|
36
|
+
* CraftNativeHealth.kt), keeping the screen awake, its brightness, the
|
|
37
|
+
* status bar, speech, and the answer to a background refresh. The same
|
|
38
|
+
* module and method names as the iOS host (CraftNativeActions.swift), so a
|
|
39
|
+
* screen's `craft.dialog`, `craft.browser`, `craft.share`, `craft.push`,
|
|
40
|
+
* `craft.health`... work unchanged.
|
|
41
|
+
*
|
|
42
|
+
* `activity` is null in a background worker, where nothing a person
|
|
43
|
+
* answers can be shown: those calls fail with `NEEDS_ACTIVITY`.
|
|
44
|
+
*/
|
|
45
|
+
internal class CraftNativeServices(
|
|
46
|
+
private val context: Context,
|
|
47
|
+
private val config: JSONObject,
|
|
48
|
+
private val activity: () -> Activity?,
|
|
49
|
+
) {
|
|
50
|
+
private val main = Handler(Looper.getMainLooper())
|
|
51
|
+
private val health: CraftNativeHealth? = if (config.optBoolean("enableHealthConnect")) {
|
|
52
|
+
CraftNativeHealth(context, activity, config.optBoolean("enableHealthConnectBackground"), config.optBoolean("enableHealthConnectHistory"))
|
|
53
|
+
} else null
|
|
54
|
+
private var pendingPushPermission: ((CraftNativeCapabilityResult) -> Unit)? = null
|
|
55
|
+
private var pendingBrowser: Pair<String?, (CraftNativeCapabilityResult) -> Unit>? = null
|
|
56
|
+
private var browserOpened = false
|
|
57
|
+
private var speech: TextToSpeech? = null
|
|
58
|
+
private var speechReady = false
|
|
59
|
+
private val speechQueue = mutableListOf<() -> Unit>()
|
|
60
|
+
private val speeches = mutableMapOf<String, (CraftNativeCapabilityResult) -> Unit>()
|
|
61
|
+
|
|
62
|
+
/** `background.complete(ok, id)`: who is waiting for a background refresh's answer. */
|
|
63
|
+
var onBackgroundComplete: ((Boolean, String?) -> Unit)? = null
|
|
64
|
+
|
|
65
|
+
/** What this host adds to the capability names the screen sees. */
|
|
66
|
+
fun names(): List<String> = buildList {
|
|
67
|
+
add("dialog")
|
|
68
|
+
add("browser")
|
|
69
|
+
add("linking")
|
|
70
|
+
add("brightness")
|
|
71
|
+
add("statusBar")
|
|
72
|
+
add("speech")
|
|
73
|
+
// Videos kept on the phone for offline (CraftNativeMedia).
|
|
74
|
+
add("media")
|
|
75
|
+
if (config.optBoolean("enableShare")) add("share")
|
|
76
|
+
if (config.optBoolean("enablePushNotifications")) add("push")
|
|
77
|
+
if (health != null) {
|
|
78
|
+
add("health")
|
|
79
|
+
// Health Connect may need installing or updating, and its grants
|
|
80
|
+
// are managed in its own screen: `health.availability()`,
|
|
81
|
+
// `health.grantedTypes()` and `health.openSettings()`.
|
|
82
|
+
add("healthAvailability")
|
|
83
|
+
}
|
|
84
|
+
if (config.optBoolean("enableKeepAwake")) add("keepAwake")
|
|
85
|
+
if (config.optBoolean("enableGeolocation") || config.optBoolean("enableBackgroundLocation")) add("geolocation")
|
|
86
|
+
if (config.optJSONObject("backgroundRefresh")?.optBoolean("enabled") == true || config.optBoolean("enablePushNotifications")) add("background")
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
fun handles(module: String): Boolean = module in setOf(
|
|
90
|
+
"Dialog", "Browser", "Linking", "Share", "Push", "Health", "KeepAwake", "Brightness", "StatusBar", "Speech", "Background",
|
|
91
|
+
"Location", "Media",
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
fun perform(module: String, method: String, args: JSONArray, completion: (CraftNativeCapabilityResult) -> Unit) {
|
|
95
|
+
val done: (CraftNativeCapabilityResult) -> Unit = { result ->
|
|
96
|
+
if (Looper.myLooper() == Looper.getMainLooper()) completion(result) else main.post { completion(result) }
|
|
97
|
+
}
|
|
98
|
+
try {
|
|
99
|
+
when (module) {
|
|
100
|
+
"Dialog" -> dialog(method, args.optJSONObject(0) ?: JSONObject(), done)
|
|
101
|
+
"Browser" -> browser(args.optString(0), args.optJSONObject(1) ?: JSONObject(), done)
|
|
102
|
+
"Linking" -> linking(method, args.optString(0), done)
|
|
103
|
+
"Share" -> share(args.optJSONObject(0) ?: JSONObject(), done)
|
|
104
|
+
"Push" -> push(method, done)
|
|
105
|
+
"Health" -> health?.perform(method, args, done) ?: done(failure("CAPABILITY_DISABLED", "Health Connect is not enabled for this app"))
|
|
106
|
+
"KeepAwake" -> keepAwake(args.optBoolean(0, true), done)
|
|
107
|
+
"Brightness" -> brightness(method, args, done)
|
|
108
|
+
"StatusBar" -> statusBar(args.optString(0, "default"), done)
|
|
109
|
+
"Speech" -> speech(method, args, done)
|
|
110
|
+
"Location" -> location(method, args, done)
|
|
111
|
+
"Media" -> when (method) {
|
|
112
|
+
"keep" -> CraftNativeMedia.keep(context, args.optString(0), args.optJSONObject(1)?.optJSONObject("headers") ?: JSONObject(), done)
|
|
113
|
+
"path" -> done(success(CraftNativeMedia.local(context, args.optString(0))?.absolutePath ?: JSONObject.NULL))
|
|
114
|
+
"prune" -> {
|
|
115
|
+
val keep = args.optJSONArray(0) ?: JSONArray()
|
|
116
|
+
done(success(CraftNativeMedia.prune(context, (0 until keep.length()).map { keep.optString(it) })))
|
|
117
|
+
}
|
|
118
|
+
"usage" -> done(success(CraftNativeMedia.usage(context)))
|
|
119
|
+
else -> done(failure("UNKNOWN_ACTION", "Unsupported Media method"))
|
|
120
|
+
}
|
|
121
|
+
"Background" -> {
|
|
122
|
+
onBackgroundComplete?.invoke(args.optBoolean(0, true), args.opt(1) as? String)
|
|
123
|
+
done(success(true))
|
|
124
|
+
}
|
|
125
|
+
else -> done(failure("UNKNOWN_ACTION", "Unsupported native API"))
|
|
126
|
+
}
|
|
127
|
+
} catch (error: Exception) {
|
|
128
|
+
Log.e("CraftNativeAndroid", "$module.$method failed", error)
|
|
129
|
+
done(failure("CRAFT_ERROR", error.message ?: "$module.$method failed"))
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// MARK: Dialogs
|
|
134
|
+
|
|
135
|
+
private fun dialog(method: String, options: JSONObject, done: (CraftNativeCapabilityResult) -> Unit) {
|
|
136
|
+
val host = activity() ?: return done(failure("NEEDS_ACTIVITY", "No screen is showing to ask on"))
|
|
137
|
+
val builder = AlertDialog.Builder(host)
|
|
138
|
+
options.optString("title").takeIf { it.isNotBlank() }?.let(builder::setTitle)
|
|
139
|
+
options.optString("message").takeIf { it.isNotBlank() }?.let(builder::setMessage)
|
|
140
|
+
var answered = false
|
|
141
|
+
fun answer(result: CraftNativeCapabilityResult) {
|
|
142
|
+
if (answered) return
|
|
143
|
+
answered = true
|
|
144
|
+
done(result)
|
|
145
|
+
}
|
|
146
|
+
when (method) {
|
|
147
|
+
"alert" -> {
|
|
148
|
+
builder.setPositiveButton(options.optString("okLabel").ifBlank { host.getString(android.R.string.ok) }) { _, _ -> answer(success(JSONObject.NULL)) }
|
|
149
|
+
builder.setOnDismissListener { answer(success(JSONObject.NULL)) }
|
|
150
|
+
}
|
|
151
|
+
"confirm" -> {
|
|
152
|
+
builder.setPositiveButton(options.optString("confirmLabel").ifBlank { host.getString(android.R.string.ok) }) { _, _ -> answer(success(true)) }
|
|
153
|
+
builder.setNegativeButton(options.optString("cancelLabel").ifBlank { host.getString(android.R.string.cancel) }) { _, _ -> answer(success(false)) }
|
|
154
|
+
builder.setOnDismissListener { answer(success(false)) }
|
|
155
|
+
}
|
|
156
|
+
"actionSheet" -> {
|
|
157
|
+
val actions = options.optJSONArray("actions") ?: JSONArray()
|
|
158
|
+
val choices = mutableListOf<Pair<String, String>>()
|
|
159
|
+
var cancelTitle: String? = null
|
|
160
|
+
for (index in 0 until actions.length()) {
|
|
161
|
+
val action = actions.optJSONObject(index) ?: continue
|
|
162
|
+
val title = action.optString("title")
|
|
163
|
+
if (action.optString("style") == "cancel") cancelTitle = title
|
|
164
|
+
else choices.add((action.optString("id").ifBlank { title }) to title)
|
|
165
|
+
}
|
|
166
|
+
// A message and a list cannot share an AlertDialog: the message
|
|
167
|
+
// goes under the title instead.
|
|
168
|
+
options.optString("message").takeIf { it.isNotBlank() }?.let { message ->
|
|
169
|
+
builder.setMessage(null)
|
|
170
|
+
builder.setTitle(listOfNotNull(options.optString("title").takeIf { it.isNotBlank() }, message).joinToString("\n"))
|
|
171
|
+
}
|
|
172
|
+
builder.setItems(choices.map { it.second }.toTypedArray()) { _, which -> answer(success(choices[which].first)) }
|
|
173
|
+
builder.setNegativeButton(cancelTitle ?: host.getString(android.R.string.cancel)) { _, _ -> answer(success(JSONObject.NULL)) }
|
|
174
|
+
builder.setOnDismissListener { answer(success(JSONObject.NULL)) }
|
|
175
|
+
}
|
|
176
|
+
else -> return done(failure("UNKNOWN_ACTION", "Unsupported Dialog method"))
|
|
177
|
+
}
|
|
178
|
+
val shown = builder.show()
|
|
179
|
+
if (method == "confirm" && options.optBoolean("destructive")) {
|
|
180
|
+
shown.getButton(AlertDialog.BUTTON_POSITIVE)?.setTextColor(android.graphics.Color.parseColor("#FF3B30"))
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// MARK: The in-app browser
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* A page in a Custom Tab over the app. `mode: 'auth'` waits for the
|
|
188
|
+
* address the sign-in returns to (`callbackScheme`), which reaches the
|
|
189
|
+
* activity as a deep link and is handed here ([deepLink]). Otherwise
|
|
190
|
+
* the answer comes when the person is back in the app.
|
|
191
|
+
*/
|
|
192
|
+
private fun browser(url: String, options: JSONObject, done: (CraftNativeCapabilityResult) -> Unit) {
|
|
193
|
+
val host = activity() ?: return done(failure("NEEDS_ACTIVITY", "No screen is showing to open a page over"))
|
|
194
|
+
val uri = runCatching { Uri.parse(url) }.getOrNull()
|
|
195
|
+
if (uri == null || uri.scheme?.lowercase() !in setOf("http", "https")) return done(failure("INVALID_ARGUMENT", "browser.open needs an http or https URL"))
|
|
196
|
+
pendingBrowser?.second?.invoke(success(JSONObject().put("cancelled", true)))
|
|
197
|
+
val callback = options.optString("callbackScheme").takeIf { options.optString("mode") == "auth" && it.isNotBlank() }
|
|
198
|
+
pendingBrowser = callback to done
|
|
199
|
+
browserOpened = true
|
|
200
|
+
try {
|
|
201
|
+
CustomTabsIntent.Builder().setShowTitle(true).build().launchUrl(host, uri)
|
|
202
|
+
} catch (_: ActivityNotFoundException) {
|
|
203
|
+
host.startActivity(Intent(Intent.ACTION_VIEW, uri))
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** A deep link arrived; true when it was the answer to an auth browser (and is not the app's to route). */
|
|
208
|
+
fun deepLink(uri: Uri): Boolean {
|
|
209
|
+
val pending = pendingBrowser ?: return false
|
|
210
|
+
val scheme = pending.first ?: return false
|
|
211
|
+
if (!uri.scheme.equals(scheme, ignoreCase = true)) return false
|
|
212
|
+
pendingBrowser = null
|
|
213
|
+
browserOpened = false
|
|
214
|
+
pending.second(success(JSONObject().put("cancelled", false).put("url", uri.toString())))
|
|
215
|
+
return true
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** The activity is in front again: a page that was open was closed. */
|
|
219
|
+
fun resumed() {
|
|
220
|
+
if (!browserOpened) return
|
|
221
|
+
browserOpened = false
|
|
222
|
+
main.postDelayed({
|
|
223
|
+
// A sign-in's deep link resumes the activity too; it answers first.
|
|
224
|
+
val pending = pendingBrowser ?: return@postDelayed
|
|
225
|
+
pendingBrowser = null
|
|
226
|
+
pending.second(success(JSONObject().put("cancelled", true)))
|
|
227
|
+
}, 400)
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// MARK: Other apps
|
|
231
|
+
|
|
232
|
+
private fun linking(method: String, url: String, done: (CraftNativeCapabilityResult) -> Unit) {
|
|
233
|
+
val intent = intentFor(url) ?: return done(success(false))
|
|
234
|
+
when (method) {
|
|
235
|
+
"canOpenURL" -> done(success(intent.resolveActivity(context.packageManager) != null || url.startsWith("http")))
|
|
236
|
+
"openURL" -> {
|
|
237
|
+
val host = activity()
|
|
238
|
+
if (host == null) intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
|
239
|
+
done(success(runCatching { (host ?: context).startActivity(intent) }.isSuccess))
|
|
240
|
+
}
|
|
241
|
+
else -> done(failure("UNKNOWN_ACTION", "Unsupported Linking method"))
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** `app-settings:` is this app's page in Settings, as on iOS; anything else is a view intent. */
|
|
246
|
+
private fun intentFor(url: String): Intent? {
|
|
247
|
+
if (url.isBlank()) return null
|
|
248
|
+
if (url.startsWith("app-settings:")) {
|
|
249
|
+
return Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, Uri.fromParts("package", context.packageName, null))
|
|
250
|
+
}
|
|
251
|
+
val uri = runCatching { Uri.parse(url) }.getOrNull() ?: return null
|
|
252
|
+
return Intent(Intent.ACTION_VIEW, uri)
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// MARK: Share
|
|
256
|
+
|
|
257
|
+
private fun share(options: JSONObject, done: (CraftNativeCapabilityResult) -> Unit) {
|
|
258
|
+
val host = activity() ?: return done(failure("NEEDS_ACTIVITY", "No screen is showing to share from"))
|
|
259
|
+
val text = listOfNotNull(
|
|
260
|
+
options.optString("text").takeIf { it.isNotBlank() },
|
|
261
|
+
options.optString("url").takeIf { it.isNotBlank() },
|
|
262
|
+
).joinToString("\n")
|
|
263
|
+
val files = mutableListOf<Uri>()
|
|
264
|
+
val paths = options.optJSONArray("files") ?: JSONArray()
|
|
265
|
+
for (index in 0 until paths.length()) {
|
|
266
|
+
val path = paths.optString(index).removePrefix("file://")
|
|
267
|
+
val file = File(path)
|
|
268
|
+
if (!file.exists()) continue
|
|
269
|
+
files.add(FileProvider.getUriForFile(context, "${context.packageName}.craftfiles", file))
|
|
270
|
+
}
|
|
271
|
+
val intent = when {
|
|
272
|
+
files.size > 1 -> Intent(Intent.ACTION_SEND_MULTIPLE).apply {
|
|
273
|
+
putParcelableArrayListExtra(Intent.EXTRA_STREAM, ArrayList(files))
|
|
274
|
+
type = "*/*"
|
|
275
|
+
}
|
|
276
|
+
files.size == 1 -> Intent(Intent.ACTION_SEND).apply {
|
|
277
|
+
putExtra(Intent.EXTRA_STREAM, files.first())
|
|
278
|
+
type = context.contentResolver.getType(files.first()) ?: "*/*"
|
|
279
|
+
}
|
|
280
|
+
else -> Intent(Intent.ACTION_SEND).apply { type = "text/plain" }
|
|
281
|
+
}
|
|
282
|
+
if (text.isNotBlank()) intent.putExtra(Intent.EXTRA_TEXT, text)
|
|
283
|
+
options.optString("title").takeIf { it.isNotBlank() }?.let { intent.putExtra(Intent.EXTRA_SUBJECT, it) }
|
|
284
|
+
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
|
285
|
+
host.startActivity(Intent.createChooser(intent, options.optString("title").ifBlank { null }))
|
|
286
|
+
// Android does not say whether something was shared; the sheet opened.
|
|
287
|
+
done(success(true))
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// MARK: Push
|
|
291
|
+
|
|
292
|
+
private fun push(method: String, done: (CraftNativeCapabilityResult) -> Unit) {
|
|
293
|
+
if (!config.optBoolean("enablePushNotifications")) return done(failure("CAPABILITY_DISABLED", "Push notifications are disabled"))
|
|
294
|
+
when (method) {
|
|
295
|
+
"getPermissionStatus" -> done(success(pushStatus()))
|
|
296
|
+
"requestPermission" -> requestPushPermission(done)
|
|
297
|
+
"register" -> requestPushPermission { status ->
|
|
298
|
+
if (status.data != "granted") done(failure("PERMISSION_DENIED", "Notifications are not allowed"))
|
|
299
|
+
else registerPush(done)
|
|
300
|
+
}
|
|
301
|
+
"openSettings" -> {
|
|
302
|
+
val intent = Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName)
|
|
303
|
+
val host = activity()
|
|
304
|
+
if (host == null) intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
|
305
|
+
done(success(runCatching { (host ?: context).startActivity(intent) }.isSuccess))
|
|
306
|
+
}
|
|
307
|
+
else -> done(failure("UNKNOWN_ACTION", "Unsupported Push method"))
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
private fun pushStatus(): String {
|
|
312
|
+
val enabled = NotificationManagerCompat.from(context).areNotificationsEnabled()
|
|
313
|
+
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return if (enabled) "granted" else "denied"
|
|
314
|
+
if (ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED) return "granted"
|
|
315
|
+
val asked = context.getSharedPreferences("craft-native-push", Context.MODE_PRIVATE).getBoolean("asked", false)
|
|
316
|
+
return if (asked) "denied" else "undetermined"
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
private fun requestPushPermission(done: (CraftNativeCapabilityResult) -> Unit) {
|
|
320
|
+
val status = pushStatus()
|
|
321
|
+
if (status != "undetermined" || Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return done(success(status))
|
|
322
|
+
val host = activity() ?: return done(success(status))
|
|
323
|
+
pendingPushPermission?.invoke(success(status))
|
|
324
|
+
pendingPushPermission = done
|
|
325
|
+
context.getSharedPreferences("craft-native-push", Context.MODE_PRIVATE).edit().putBoolean("asked", true).apply()
|
|
326
|
+
ActivityCompat.requestPermissions(host, arrayOf(Manifest.permission.POST_NOTIFICATIONS), PUSH_PERMISSION_REQUEST)
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
fun onRequestPermissionsResult(requestCode: Int): Boolean {
|
|
330
|
+
if (requestCode == LOCATION_PERMISSION_REQUEST) {
|
|
331
|
+
val pending = pendingLocationPermission
|
|
332
|
+
pendingLocationPermission = null
|
|
333
|
+
pending?.invoke(hasLocation())
|
|
334
|
+
return true
|
|
335
|
+
}
|
|
336
|
+
if (requestCode != PUSH_PERMISSION_REQUEST) return false
|
|
337
|
+
val pending = pendingPushPermission ?: return true
|
|
338
|
+
pendingPushPermission = null
|
|
339
|
+
pending(success(pushStatus()))
|
|
340
|
+
return true
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
private fun registerPush(done: (CraftNativeCapabilityResult) -> Unit) {
|
|
344
|
+
{{NATIVE_PUSH_REGISTER}}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// MARK: Location
|
|
348
|
+
|
|
349
|
+
private val locationClient by lazy { com.google.android.gms.location.LocationServices.getFusedLocationProviderClient(context) }
|
|
350
|
+
private var watchCallback: com.google.android.gms.location.LocationCallback? = null
|
|
351
|
+
private var pendingLocationPermission: ((Boolean) -> Unit)? = null
|
|
352
|
+
|
|
353
|
+
private fun fix(location: android.location.Location): JSONObject = JSONObject()
|
|
354
|
+
.put("latitude", location.latitude)
|
|
355
|
+
.put("longitude", location.longitude)
|
|
356
|
+
.put("altitude", location.altitude)
|
|
357
|
+
.put("accuracy", location.accuracy.toDouble())
|
|
358
|
+
.put("speed", location.speed.toDouble())
|
|
359
|
+
.put("heading", location.bearing.toDouble())
|
|
360
|
+
.put("timestamp", location.time.toDouble())
|
|
361
|
+
|
|
362
|
+
private fun hasLocation(): Boolean =
|
|
363
|
+
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED
|
|
364
|
+
|| ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED
|
|
365
|
+
|
|
366
|
+
/** Location, asked for once a person starts something that needs it. */
|
|
367
|
+
private fun withLocation(done: (CraftNativeCapabilityResult) -> Unit, then: () -> Unit) {
|
|
368
|
+
if (hasLocation()) return then()
|
|
369
|
+
val host = activity() ?: return done(failure("PERMISSION_DENIED", "Location is not allowed"))
|
|
370
|
+
pendingLocationPermission?.invoke(false)
|
|
371
|
+
pendingLocationPermission = { granted -> if (granted) then() else done(failure("PERMISSION_DENIED", "Location is not allowed")) }
|
|
372
|
+
ActivityCompat.requestPermissions(host, arrayOf(Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION), LOCATION_PERMISSION_REQUEST)
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* GPS for the workout player, in the iOS host's names: one fix, a watch
|
|
377
|
+
* (each fix a `craftLocationUpdate` event), and the route recording,
|
|
378
|
+
* which keeps going with the screen off in a foreground service
|
|
379
|
+
* (LocationRecordingService) and survives the app being closed.
|
|
380
|
+
*/
|
|
381
|
+
@android.annotation.SuppressLint("MissingPermission")
|
|
382
|
+
private fun location(method: String, args: JSONArray, done: (CraftNativeCapabilityResult) -> Unit) {
|
|
383
|
+
val store = com.craft.runtime.CraftLocationRecordingStore
|
|
384
|
+
when (method) {
|
|
385
|
+
"getCurrentPosition" -> withLocation(done) {
|
|
386
|
+
val options = args.optJSONObject(0) ?: JSONObject()
|
|
387
|
+
val priority = if (options.optBoolean("enableHighAccuracy", true)) com.google.android.gms.location.Priority.PRIORITY_HIGH_ACCURACY
|
|
388
|
+
else com.google.android.gms.location.Priority.PRIORITY_BALANCED_POWER_ACCURACY
|
|
389
|
+
locationClient.getCurrentLocation(priority, null)
|
|
390
|
+
.addOnSuccessListener { location -> done(if (location != null) success(fix(location)) else failure("POSITION_UNAVAILABLE", "No location fix")) }
|
|
391
|
+
.addOnFailureListener { error -> done(failure("POSITION_UNAVAILABLE", error.message ?: "No location fix")) }
|
|
392
|
+
}
|
|
393
|
+
"watchPosition" -> withLocation(done) {
|
|
394
|
+
watchCallback?.let(locationClient::removeLocationUpdates)
|
|
395
|
+
val callback = object : com.google.android.gms.location.LocationCallback() {
|
|
396
|
+
override fun onLocationResult(result: com.google.android.gms.location.LocationResult) {
|
|
397
|
+
for (location in result.locations) {
|
|
398
|
+
CraftNativeHub.broadcast("CRAFT_EVENT", JSONObject().put("event", "craftLocationUpdate").put("detail", fix(location)))
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
watchCallback = callback
|
|
403
|
+
val request = com.google.android.gms.location.LocationRequest.Builder(com.google.android.gms.location.Priority.PRIORITY_HIGH_ACCURACY, 2_000).build()
|
|
404
|
+
locationClient.requestLocationUpdates(request, callback, Looper.getMainLooper())
|
|
405
|
+
done(success(true))
|
|
406
|
+
}
|
|
407
|
+
"clearWatch" -> {
|
|
408
|
+
watchCallback?.let(locationClient::removeLocationUpdates)
|
|
409
|
+
watchCallback = null
|
|
410
|
+
done(success(true))
|
|
411
|
+
}
|
|
412
|
+
"startRecording" -> withLocation(done) {
|
|
413
|
+
val options = args.optJSONObject(0) ?: JSONObject()
|
|
414
|
+
val id = options.optString("id").ifBlank { UUID.randomUUID().toString() }
|
|
415
|
+
store.start(context, id, System.currentTimeMillis())
|
|
416
|
+
store.listener = { location -> CraftNativeHub.broadcast("CRAFT_EVENT", JSONObject().put("event", "craftLocationUpdate").put("detail", fix(location))) }
|
|
417
|
+
val intent = Intent(context, com.craft.runtime.LocationRecordingService::class.java).setAction(com.craft.runtime.LocationRecordingService.ACTION_START)
|
|
418
|
+
runCatching { ContextCompat.startForegroundService(context, intent) }
|
|
419
|
+
.onFailure { store.stop(context) }
|
|
420
|
+
done(success(store.state(context)))
|
|
421
|
+
}
|
|
422
|
+
"pauseRecording", "resumeRecording" -> {
|
|
423
|
+
store.setPaused(context, method == "pauseRecording")
|
|
424
|
+
done(success(store.state(context)))
|
|
425
|
+
}
|
|
426
|
+
"stopRecording" -> {
|
|
427
|
+
val state = store.state(context, includeLocations = true)
|
|
428
|
+
store.stop(context)
|
|
429
|
+
store.listener = null
|
|
430
|
+
runCatching { context.startService(Intent(context, com.craft.runtime.LocationRecordingService::class.java).setAction(com.craft.runtime.LocationRecordingService.ACTION_STOP)) }
|
|
431
|
+
done(success(state.put("active", false).put("paused", false)))
|
|
432
|
+
}
|
|
433
|
+
"getRecordingState" -> done(success(store.state(context)))
|
|
434
|
+
"readRecording" -> done(success(store.locations(context)))
|
|
435
|
+
else -> done(failure("UNKNOWN_ACTION", "Unsupported Location method"))
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// MARK: Health Connect
|
|
440
|
+
|
|
441
|
+
fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?): Boolean =
|
|
442
|
+
health?.onActivityResult(requestCode, resultCode, data) == true
|
|
443
|
+
|
|
444
|
+
// MARK: The screen
|
|
445
|
+
|
|
446
|
+
private fun keepAwake(enabled: Boolean, done: (CraftNativeCapabilityResult) -> Unit) {
|
|
447
|
+
val host = activity() ?: return done(success(false))
|
|
448
|
+
if (enabled) host.window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
|
449
|
+
else host.window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
|
450
|
+
done(success(enabled))
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/** This window's brightness, 0 to 1 (Android sets it for the app, not system-wide); -1 restores the system's. */
|
|
454
|
+
private fun brightness(method: String, args: JSONArray, done: (CraftNativeCapabilityResult) -> Unit) {
|
|
455
|
+
val host = activity() ?: return done(failure("NEEDS_ACTIVITY", "No screen is showing"))
|
|
456
|
+
val attributes = host.window.attributes
|
|
457
|
+
when (method) {
|
|
458
|
+
"get" -> {
|
|
459
|
+
val own = attributes.screenBrightness
|
|
460
|
+
val level = if (own >= 0) own.toDouble() else runCatching {
|
|
461
|
+
Settings.System.getInt(context.contentResolver, Settings.System.SCREEN_BRIGHTNESS) / 255.0
|
|
462
|
+
}.getOrDefault(0.5)
|
|
463
|
+
done(success(level))
|
|
464
|
+
}
|
|
465
|
+
"set" -> {
|
|
466
|
+
val level = args.optDouble(0, -1.0)
|
|
467
|
+
attributes.screenBrightness = if (level < 0) WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_NONE else level.coerceIn(0.0, 1.0).toFloat()
|
|
468
|
+
host.window.attributes = attributes
|
|
469
|
+
done(success(level))
|
|
470
|
+
}
|
|
471
|
+
else -> done(failure("UNKNOWN_ACTION", "Unsupported Brightness method"))
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/** `light` is light text (on a dark screen), as on iOS. */
|
|
476
|
+
private fun statusBar(style: String, done: (CraftNativeCapabilityResult) -> Unit) {
|
|
477
|
+
val host = activity() ?: return done(success(false))
|
|
478
|
+
val controller = WindowCompat.getInsetsController(host.window, host.window.decorView)
|
|
479
|
+
val night = (host.resources.configuration.uiMode and android.content.res.Configuration.UI_MODE_NIGHT_MASK) == android.content.res.Configuration.UI_MODE_NIGHT_YES
|
|
480
|
+
controller.isAppearanceLightStatusBars = when (style) {
|
|
481
|
+
"light" -> false
|
|
482
|
+
"dark" -> true
|
|
483
|
+
else -> !night
|
|
484
|
+
}
|
|
485
|
+
done(success(true))
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// MARK: Speech
|
|
489
|
+
|
|
490
|
+
private fun speech(method: String, args: JSONArray, done: (CraftNativeCapabilityResult) -> Unit) {
|
|
491
|
+
when (method) {
|
|
492
|
+
"speak" -> {
|
|
493
|
+
val text = args.optString(0)
|
|
494
|
+
if (text.isBlank()) return done(failure("INVALID_ARGUMENT", "speech.speak needs some text to say"))
|
|
495
|
+
val options = args.optJSONObject(1) ?: JSONObject()
|
|
496
|
+
withSpeech { engine ->
|
|
497
|
+
options.optString("language").takeIf { it.isNotBlank() }?.let { engine.language = Locale.forLanguageTag(it) }
|
|
498
|
+
if (options.has("rate")) engine.setSpeechRate((options.optDouble("rate", 0.5) * 2).toFloat().coerceIn(0.1f, 4f))
|
|
499
|
+
val id = UUID.randomUUID().toString()
|
|
500
|
+
speeches[id] = done
|
|
501
|
+
val queue = if (options.optBoolean("interrupt", false)) TextToSpeech.QUEUE_FLUSH else TextToSpeech.QUEUE_ADD
|
|
502
|
+
if (engine.speak(text, queue, null, id) != TextToSpeech.SUCCESS) speeches.remove(id)?.invoke(success(false))
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
"stop" -> {
|
|
506
|
+
speech?.stop()
|
|
507
|
+
speeches.values.toList().forEach { it(success(false)) }
|
|
508
|
+
speeches.clear()
|
|
509
|
+
done(success(JSONObject.NULL))
|
|
510
|
+
}
|
|
511
|
+
else -> done(failure("UNKNOWN_ACTION", "Unsupported Speech method"))
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
private fun withSpeech(use: (TextToSpeech) -> Unit) {
|
|
516
|
+
val engine = speech
|
|
517
|
+
if (engine != null && speechReady) return use(engine)
|
|
518
|
+
speechQueue.add { speech?.let(use) }
|
|
519
|
+
if (engine != null) return
|
|
520
|
+
speech = TextToSpeech(context) { status ->
|
|
521
|
+
main.post {
|
|
522
|
+
speechReady = status == TextToSpeech.SUCCESS
|
|
523
|
+
if (!speechReady) {
|
|
524
|
+
speechQueue.clear()
|
|
525
|
+
speeches.values.toList().forEach { it(success(false)) }
|
|
526
|
+
speeches.clear()
|
|
527
|
+
return@post
|
|
528
|
+
}
|
|
529
|
+
speech?.setOnUtteranceProgressListener(object : UtteranceProgressListener() {
|
|
530
|
+
override fun onStart(utteranceId: String?) {}
|
|
531
|
+
override fun onDone(utteranceId: String?) { main.post { speeches.remove(utteranceId)?.invoke(success(true)) } }
|
|
532
|
+
@Deprecated("Kept for older engines")
|
|
533
|
+
override fun onError(utteranceId: String?) { main.post { speeches.remove(utteranceId)?.invoke(success(false)) } }
|
|
534
|
+
override fun onStop(utteranceId: String?, interrupted: Boolean) { main.post { speeches.remove(utteranceId)?.invoke(success(false)) } }
|
|
535
|
+
})
|
|
536
|
+
val queued = speechQueue.toList()
|
|
537
|
+
speechQueue.clear()
|
|
538
|
+
queued.forEach { it() }
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
fun close() {
|
|
544
|
+
watchCallback?.let(locationClient::removeLocationUpdates)
|
|
545
|
+
watchCallback = null
|
|
546
|
+
pendingLocationPermission = null
|
|
547
|
+
health?.close()
|
|
548
|
+
speech?.shutdown()
|
|
549
|
+
speech = null
|
|
550
|
+
pendingBrowser = null
|
|
551
|
+
pendingPushPermission = null
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
private fun success(data: Any?) = CraftNativeCapabilityResult(data = data ?: JSONObject.NULL)
|
|
555
|
+
private fun failure(code: String, message: String) = CraftNativeCapabilityResult(code = code, message = message)
|
|
556
|
+
|
|
557
|
+
companion object {
|
|
558
|
+
const val PUSH_PERMISSION_REQUEST = 4207
|
|
559
|
+
const val LOCATION_PERMISSION_REQUEST = 4208
|
|
560
|
+
}
|
|
561
|
+
}
|