craft-native 0.0.118 → 0.0.119
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.js +5 -1
- package/dist/android/templates/AndroidManifest.xml.template +1 -0
- package/dist/android/templates/CraftBridge.kt.template +3 -0
- package/dist/android/templates/CraftNativeCapabilities.kt.template +455 -0
- package/dist/android/templates/CraftNativeFlatList.kt.template +27 -5
- package/dist/android/templates/CraftNativeNotificationReceiver.kt.template +43 -0
- package/dist/android/templates/MainActivityNative.kt.template +229 -47
- package/dist/api/mobile.d.ts +24 -1
- package/dist/cli.js +1 -1
- package/dist/index.cjs +26 -1
- package/dist/index.js +26 -1
- package/dist/ios/src/index.d.ts +18 -0
- package/dist/ios/src/index.js +29 -1
- package/dist/ios/templates/CraftApp.swift +39 -1
- package/dist/ios/templates/CraftNativeActions.swift +472 -31
- package/dist/ios/templates/CraftNativeScreen.swift +93 -22
- package/dist/ios/templates/Info.plist.template +1 -0
- package/dist/mobile.js +25 -0
- package/package.json +1 -1
|
@@ -418,8 +418,12 @@ async function init(options) {
|
|
|
418
418
|
if (config.renderer === "native") {
|
|
419
419
|
const mutationTemplate = readFileSync(join(TEMPLATES_DIR, "CraftNativeMutation.kt.template"), "utf-8");
|
|
420
420
|
const flatListTemplate = readFileSync(join(TEMPLATES_DIR, "CraftNativeFlatList.kt.template"), "utf-8");
|
|
421
|
+
const capabilityTemplate = readFileSync(join(TEMPLATES_DIR, "CraftNativeCapabilities.kt.template"), "utf-8");
|
|
422
|
+
const notificationTemplate = readFileSync(join(TEMPLATES_DIR, "CraftNativeNotificationReceiver.kt.template"), "utf-8");
|
|
421
423
|
writeFileSync(join(output, "app/src/main/java", packagePath, "CraftNativeMutation.kt"), mutationTemplate.replace(/\{\{PACKAGE_NAME\}\}/g, finalPackageName));
|
|
422
424
|
writeFileSync(join(output, "app/src/main/java", packagePath, "CraftNativeFlatList.kt"), flatListTemplate.replace(/\{\{PACKAGE_NAME\}\}/g, finalPackageName));
|
|
425
|
+
writeFileSync(join(output, "app/src/main/java", packagePath, "CraftNativeCapabilities.kt"), capabilityTemplate.replace(/\{\{PACKAGE_NAME\}\}/g, finalPackageName));
|
|
426
|
+
writeFileSync(join(output, "app/src/main/java", packagePath, "CraftNativeNotificationReceiver.kt"), notificationTemplate.replace(/\{\{PACKAGE_NAME\}\}/g, finalPackageName));
|
|
423
427
|
}
|
|
424
428
|
const craftBridgeTemplate = readFileSync(join(TEMPLATES_DIR, "CraftBridge.kt.template"), "utf-8");
|
|
425
429
|
const craftBridge = craftBridgeTemplate.replace(/\{\{PROMISE_RUNTIME\}\}/g, () => renderAndroidPromiseRuntime(" ")).replace(/\{\{PACKAGE_NAME\}\}/g, finalPackageName).replace(/\{\{ENABLE_SPEECH\}\}/g, String(Boolean(config.enableSpeechRecognition))).replace(/\{\{ENABLE_HAPTICS\}\}/g, String(Boolean(config.enableHaptics))).replace(/\{\{ENABLE_SHARE\}\}/g, String(Boolean(config.enableShare))).replace(/\{\{ENABLE_CAMERA\}\}/g, String(Boolean(config.enableCamera))).replace(/\{\{ENABLE_BIOMETRIC\}\}/g, String(Boolean(config.enableBiometric))).replace(/\{\{ENABLE_PUSH\}\}/g, String(Boolean(config.enablePushNotifications))).replace(/\{\{ENABLE_SECURE_STORAGE\}\}/g, String(Boolean(config.enableSecureStorage))).replace(/\{\{ENABLE_GEOLOCATION\}\}/g, String(Boolean(config.enableGeolocation))).replace(/\{\{ENABLE_BACKGROUND_LOCATION\}\}/g, String(Boolean(config.enableBackgroundLocation))).replace(/\{\{ENABLE_KEEP_AWAKE\}\}/g, String(Boolean(config.enableKeepAwake))).replace(/\{\{ENABLE_DEEP_LINKS\}\}/g, String(Boolean(config.enableDeepLinks))).replace(/\{\{ENABLE_HEALTH_CONNECT\}\}/g, String(Boolean(config.enableHealthConnect))).replace(/\{\{FIREBASE_IMPORT\}\}/g, config.enablePushNotifications ? "import com.google.firebase.messaging.FirebaseMessaging" : "").replace(/\{\{REGISTER_PUSH_IMPLEMENTATION\}\}/g, config.enablePushNotifications ? `activity.runOnUiThread {
|
|
@@ -451,7 +455,7 @@ async function init(options) {
|
|
|
451
455
|
const healthTemplate = readFileSync(join(TEMPLATES_DIR, config.enableHealthConnect ? "CraftHealthConnect.kt.template" : "CraftHealthConnectStub.kt.template"), "utf-8");
|
|
452
456
|
writeFileSync(join(output, "app/src/main/java", packagePath, "CraftHealthConnect.kt"), healthTemplate.replace(/\{\{PACKAGE_NAME\}\}/g, finalPackageName));
|
|
453
457
|
const manifestTemplate = readFileSync(join(TEMPLATES_DIR, "AndroidManifest.xml.template"), "utf-8");
|
|
454
|
-
const manifest = manifestTemplate.replace(/\{\{PACKAGE_NAME\}\}/g, finalPackageName).replace(/\{\{APP_NAME\}\}/g, name).replace(/\{\{PERMISSIONS\}\}/g, renderAndroidPermissions(config)).replace(/\{\{USES_CLEARTEXT\}\}/g, config.devServerURL?.startsWith("http://") || config.enableLocalNetwork ? "true" : "false").replace(/\{\{DEEP_LINK_INTENT_FILTERS\}\}/g, renderAndroidDeepLinks(config)).replace(/\{\{BACKGROUND_SERVICE\}\}/g, config.enableBackgroundLocation ? ' <service android:name="com.craft.runtime.LocationRecordingService" android:exported="false" android:foregroundServiceType="location" android:stopWithTask="false" />' : "").replace(/\{\{HEALTH_CONNECT_QUERIES\}\}/g, config.enableHealthConnect ? ` <queries>
|
|
458
|
+
const manifest = manifestTemplate.replace(/\{\{PACKAGE_NAME\}\}/g, finalPackageName).replace(/\{\{APP_NAME\}\}/g, name).replace(/\{\{PERMISSIONS\}\}/g, renderAndroidPermissions(config)).replace(/\{\{USES_CLEARTEXT\}\}/g, config.devServerURL?.startsWith("http://") || config.enableLocalNetwork ? "true" : "false").replace(/\{\{DEEP_LINK_INTENT_FILTERS\}\}/g, renderAndroidDeepLinks(config)).replace(/\{\{BACKGROUND_SERVICE\}\}/g, config.enableBackgroundLocation ? ' <service android:name="com.craft.runtime.LocationRecordingService" android:exported="false" android:foregroundServiceType="location" android:stopWithTask="false" />' : "").replace(/\{\{NATIVE_NOTIFICATION_RECEIVER\}\}/g, config.renderer === "native" ? ' <receiver android:name=".CraftNativeNotificationReceiver" android:exported="false" />' : "").replace(/\{\{HEALTH_CONNECT_QUERIES\}\}/g, config.enableHealthConnect ? ` <queries>
|
|
455
459
|
<package android:name="com.google.android.apps.healthdata" />
|
|
456
460
|
</queries>` : "").replace(/\{\{HEALTH_CONNECT_RATIONALE\}\}/g, config.enableHealthConnect ? ` <intent-filter>
|
|
457
461
|
<action android:name="androidx.health.ACTION_SHOW_PERMISSIONS_RATIONALE" />
|
|
@@ -1620,6 +1620,9 @@ class CraftBridge(
|
|
|
1620
1620
|
craft.secureStorage = {
|
|
1621
1621
|
set: function(key, value) { return Promise.resolve().then(function() { legacySecureStore.set(key, value); }); },
|
|
1622
1622
|
get: function(key) { return Promise.resolve().then(function() { return legacySecureStore.get(key); }); },
|
|
1623
|
+
// Keep the legacy public name alongside `delete` for
|
|
1624
|
+
// native STX callers compiled against the older contract.
|
|
1625
|
+
remove: function(key) { return Promise.resolve().then(function() { legacySecureStore.remove(key); }); },
|
|
1623
1626
|
delete: function(key) { return Promise.resolve().then(function() { legacySecureStore.remove(key); }); },
|
|
1624
1627
|
clear: function() { return Promise.resolve().then(function() { CraftAndroid.secureClear(); }); }
|
|
1625
1628
|
};
|
|
@@ -0,0 +1,455 @@
|
|
|
1
|
+
package {{PACKAGE_NAME}}
|
|
2
|
+
|
|
3
|
+
import android.Manifest
|
|
4
|
+
import android.app.AlarmManager
|
|
5
|
+
import android.app.NotificationManager
|
|
6
|
+
import android.app.PendingIntent
|
|
7
|
+
import android.content.Context
|
|
8
|
+
import android.content.Intent
|
|
9
|
+
import android.content.pm.PackageManager
|
|
10
|
+
import android.database.Cursor
|
|
11
|
+
import android.database.sqlite.SQLiteCursor
|
|
12
|
+
import android.database.sqlite.SQLiteDatabase
|
|
13
|
+
import android.database.sqlite.SQLiteQuery
|
|
14
|
+
import android.database.sqlite.SQLiteStatement
|
|
15
|
+
import android.os.Build
|
|
16
|
+
import android.net.Uri
|
|
17
|
+
import android.util.Base64
|
|
18
|
+
import androidx.core.content.ContextCompat
|
|
19
|
+
import androidx.biometric.BiometricManager
|
|
20
|
+
import androidx.biometric.BiometricPrompt
|
|
21
|
+
import androidx.fragment.app.FragmentActivity
|
|
22
|
+
import androidx.security.crypto.EncryptedSharedPreferences
|
|
23
|
+
import androidx.security.crypto.MasterKey
|
|
24
|
+
import org.json.JSONArray
|
|
25
|
+
import org.json.JSONObject
|
|
26
|
+
import org.json.JSONTokener
|
|
27
|
+
import java.util.UUID
|
|
28
|
+
import java.util.concurrent.ConcurrentHashMap
|
|
29
|
+
import java.util.concurrent.Executors
|
|
30
|
+
|
|
31
|
+
internal const val CRAFT_NATIVE_CAPABILITY_PROTOCOL_VERSION = 1
|
|
32
|
+
|
|
33
|
+
internal data class CraftNativeCapabilityResult(
|
|
34
|
+
val data: Any? = null,
|
|
35
|
+
val code: String? = null,
|
|
36
|
+
val message: String? = null,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
/** Process-wide persistence and OS services shared by every native route. */
|
|
40
|
+
internal class CraftNativeCapabilities(
|
|
41
|
+
private val activity: MainActivity,
|
|
42
|
+
private val config: JSONObject,
|
|
43
|
+
) {
|
|
44
|
+
private val storage = activity.getSharedPreferences("craft-native-storage", Context.MODE_PRIVATE)
|
|
45
|
+
// The typed native renderer has no CraftNative Zig holder. Keep its
|
|
46
|
+
// values encrypted at rest with AndroidX's AES-256 GCM/SIV preferences.
|
|
47
|
+
private val encryptedSecureStorage by lazy {
|
|
48
|
+
val masterKey = MasterKey.Builder(activity)
|
|
49
|
+
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
|
50
|
+
.build()
|
|
51
|
+
EncryptedSharedPreferences.create(
|
|
52
|
+
activity,
|
|
53
|
+
"craft_secure_prefs",
|
|
54
|
+
masterKey,
|
|
55
|
+
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
|
56
|
+
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
|
|
57
|
+
)
|
|
58
|
+
}
|
|
59
|
+
private val notificationStorage = activity.getSharedPreferences("craft-native-notifications", Context.MODE_PRIVATE)
|
|
60
|
+
private val databaseExecutor = Executors.newSingleThreadExecutor()
|
|
61
|
+
private val cancelled = ConcurrentHashMap.newKeySet<String>()
|
|
62
|
+
private var database: SQLiteDatabase? = null
|
|
63
|
+
private val biometricPrompts = ConcurrentHashMap<String, BiometricPrompt>()
|
|
64
|
+
|
|
65
|
+
init { restoreNotifications() }
|
|
66
|
+
|
|
67
|
+
fun names(): JSONArray = JSONArray().apply {
|
|
68
|
+
listOf("device", "clipboard", "haptics", "storage", "lifecycle").forEach(::put)
|
|
69
|
+
if (config.optBoolean("enableBiometric")) put("biometric")
|
|
70
|
+
if (config.optBoolean("enableSecureStorage")) put("secureStorage")
|
|
71
|
+
if (config.optBoolean("enableLocalDatabase")) put("database")
|
|
72
|
+
if (config.optBoolean("enableDeepLinks")) put("deepLinks")
|
|
73
|
+
if (config.optBoolean("enableLocalNotifications")) put("notifications")
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
fun perform(
|
|
77
|
+
requestToken: String,
|
|
78
|
+
version: Int,
|
|
79
|
+
module: String,
|
|
80
|
+
method: String,
|
|
81
|
+
args: JSONArray,
|
|
82
|
+
completion: (CraftNativeCapabilityResult) -> Unit,
|
|
83
|
+
) {
|
|
84
|
+
cancelled.remove(requestToken)
|
|
85
|
+
if (version != CRAFT_NATIVE_CAPABILITY_PROTOCOL_VERSION) {
|
|
86
|
+
complete(requestToken, failure("UNSUPPORTED_VERSION", "Unsupported native capability protocol version"), completion)
|
|
87
|
+
return
|
|
88
|
+
}
|
|
89
|
+
when (module) {
|
|
90
|
+
"Storage" -> complete(requestToken, storage(method, args), completion)
|
|
91
|
+
"SecureStorage" -> {
|
|
92
|
+
if (!config.optBoolean("enableSecureStorage")) complete(requestToken, failure("CAPABILITY_DISABLED", "Secure storage is disabled"), completion)
|
|
93
|
+
else complete(requestToken, secureStorage(method, args), completion)
|
|
94
|
+
}
|
|
95
|
+
"Biometrics" -> biometrics(requestToken, method, args, completion)
|
|
96
|
+
"Database" -> {
|
|
97
|
+
if (!config.optBoolean("enableLocalDatabase")) {
|
|
98
|
+
complete(requestToken, failure("CAPABILITY_DISABLED", "Local database is disabled"), completion)
|
|
99
|
+
}
|
|
100
|
+
else databaseExecutor.execute {
|
|
101
|
+
complete(requestToken, database(method, args), completion)
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
"Lifecycle" -> complete(requestToken, if (method == "getState") success(activity.nativeAppState)
|
|
105
|
+
else failure("UNKNOWN_ACTION", "Unsupported Lifecycle method"), completion)
|
|
106
|
+
"DeepLinks" -> complete(requestToken, deepLinks(method), completion)
|
|
107
|
+
"Notifications" -> notifications(requestToken, method, args, completion)
|
|
108
|
+
else -> complete(requestToken, activity.performImmediateCapability(module, method, args), completion)
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
fun cancel(requestToken: String) {
|
|
113
|
+
cancelled.add(requestToken)
|
|
114
|
+
biometricPrompts.remove(requestToken)?.cancelAuthentication()
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
fun close() {
|
|
118
|
+
databaseExecutor.shutdownNow()
|
|
119
|
+
database?.close()
|
|
120
|
+
database = null
|
|
121
|
+
biometricPrompts.values.forEach { it.cancelAuthentication() }
|
|
122
|
+
biometricPrompts.clear()
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
private fun complete(
|
|
126
|
+
requestToken: String,
|
|
127
|
+
result: CraftNativeCapabilityResult,
|
|
128
|
+
completion: (CraftNativeCapabilityResult) -> Unit,
|
|
129
|
+
) {
|
|
130
|
+
activity.runOnUiThread {
|
|
131
|
+
if (!cancelled.remove(requestToken)) completion(result)
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
private fun storage(method: String, args: JSONArray): CraftNativeCapabilityResult {
|
|
136
|
+
if (method == "clear") {
|
|
137
|
+
storage.edit().clear().commit()
|
|
138
|
+
return success(true)
|
|
139
|
+
}
|
|
140
|
+
if (method == "keys") return success(JSONArray(storage.all.keys.sorted()))
|
|
141
|
+
val key = validKey(args.opt(0)) ?: return failure("INVALID_ARGUMENT", "Storage needs a non-empty key")
|
|
142
|
+
return when (method) {
|
|
143
|
+
"get" -> {
|
|
144
|
+
val raw = storage.getString(key, null) ?: return success(JSONObject.NULL)
|
|
145
|
+
runCatching { success(JSONTokener(raw).nextValue()) }
|
|
146
|
+
.getOrElse { failure("STORAGE_CORRUPT", "Stored value is not valid JSON") }
|
|
147
|
+
}
|
|
148
|
+
"set" -> {
|
|
149
|
+
if (args.length() != 2) return failure("INVALID_ARGUMENT", "Storage.set needs a key and value")
|
|
150
|
+
val value = jsonString(args.opt(1))
|
|
151
|
+
?: return failure("INVALID_ARGUMENT", "Storage values must be JSON serializable")
|
|
152
|
+
if (storage.edit().putString(key, value).commit()) success(true)
|
|
153
|
+
else failure("STORAGE_ERROR", "Storage could not persist the value")
|
|
154
|
+
}
|
|
155
|
+
"remove" -> success(storage.edit().remove(key).commit())
|
|
156
|
+
else -> failure("UNKNOWN_ACTION", "Unsupported Storage method")
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
private fun secureStorage(method: String, args: JSONArray): CraftNativeCapabilityResult {
|
|
161
|
+
if (method == "clear") {
|
|
162
|
+
return try {
|
|
163
|
+
success(encryptedSecureStorage.edit().clear().commit())
|
|
164
|
+
} catch (error: Exception) {
|
|
165
|
+
failure("SECURE_STORAGE_ERROR", error.message ?: "Secure storage could not be cleared")
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
val key = validKey(args.opt(0)) ?: return failure("INVALID_ARGUMENT", "Secure storage needs a non-empty key")
|
|
169
|
+
return try {
|
|
170
|
+
when (method) {
|
|
171
|
+
"set" -> {
|
|
172
|
+
if (args.length() != 2 || args.opt(1) !is String) return failure("INVALID_ARGUMENT", "SecureStorage.set needs a key and string value")
|
|
173
|
+
if (encryptedSecureStorage.edit().putString(key, args.getString(1)).commit()) success(true)
|
|
174
|
+
else failure("SECURE_STORAGE_ERROR", "Secure storage could not be written")
|
|
175
|
+
}
|
|
176
|
+
"get" -> success(encryptedSecureStorage.getString(key, null) ?: JSONObject.NULL)
|
|
177
|
+
"remove" -> success(encryptedSecureStorage.edit().remove(key).commit())
|
|
178
|
+
else -> failure("UNKNOWN_ACTION", "Unsupported SecureStorage method")
|
|
179
|
+
}
|
|
180
|
+
} catch (error: Exception) {
|
|
181
|
+
failure("SECURE_STORAGE_ERROR", error.message ?: "Secure storage operation failed")
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
private fun biometrics(
|
|
186
|
+
requestToken: String,
|
|
187
|
+
method: String,
|
|
188
|
+
args: JSONArray,
|
|
189
|
+
completion: (CraftNativeCapabilityResult) -> Unit,
|
|
190
|
+
) {
|
|
191
|
+
if (!config.optBoolean("enableBiometric")) {
|
|
192
|
+
complete(requestToken, failure("CAPABILITY_DISABLED", "Biometric authentication is disabled"), completion)
|
|
193
|
+
return
|
|
194
|
+
}
|
|
195
|
+
val manager = BiometricManager.from(activity)
|
|
196
|
+
when (method) {
|
|
197
|
+
"isAvailable" -> complete(requestToken, success(manager.canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_STRONG) == BiometricManager.BIOMETRIC_SUCCESS), completion)
|
|
198
|
+
"getBiometricType" -> {
|
|
199
|
+
val available = manager.canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_STRONG) == BiometricManager.BIOMETRIC_SUCCESS
|
|
200
|
+
val type = when {
|
|
201
|
+
!available -> "none"
|
|
202
|
+
activity.packageManager.hasSystemFeature("android.hardware.biometrics.face") -> "faceId"
|
|
203
|
+
else -> "fingerprint"
|
|
204
|
+
}
|
|
205
|
+
complete(requestToken, success(type), completion)
|
|
206
|
+
}
|
|
207
|
+
"authenticate" -> {
|
|
208
|
+
if (activity !is FragmentActivity) {
|
|
209
|
+
complete(requestToken, failure("AUTHENTICATION_FAILED", "Activity does not support biometric prompts"), completion)
|
|
210
|
+
return
|
|
211
|
+
}
|
|
212
|
+
val reason = args.optString(0).ifBlank { "Authenticate to continue" }
|
|
213
|
+
activity.runOnUiThread {
|
|
214
|
+
if (cancelled.contains(requestToken)) return@runOnUiThread
|
|
215
|
+
try {
|
|
216
|
+
val prompt = BiometricPrompt(activity, ContextCompat.getMainExecutor(activity), object : BiometricPrompt.AuthenticationCallback() {
|
|
217
|
+
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
|
|
218
|
+
biometricPrompts.remove(requestToken)
|
|
219
|
+
complete(requestToken, success(true), completion)
|
|
220
|
+
}
|
|
221
|
+
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
|
|
222
|
+
biometricPrompts.remove(requestToken)
|
|
223
|
+
complete(requestToken, failure("AUTHENTICATION_FAILED", errString.toString()), completion)
|
|
224
|
+
}
|
|
225
|
+
})
|
|
226
|
+
biometricPrompts[requestToken] = prompt
|
|
227
|
+
prompt.authenticate(BiometricPrompt.PromptInfo.Builder()
|
|
228
|
+
.setTitle("Authenticate")
|
|
229
|
+
.setSubtitle(reason)
|
|
230
|
+
.setNegativeButtonText("Cancel")
|
|
231
|
+
.setAllowedAuthenticators(BiometricManager.Authenticators.BIOMETRIC_STRONG)
|
|
232
|
+
.build())
|
|
233
|
+
} catch (error: Exception) {
|
|
234
|
+
complete(requestToken, failure("AUTHENTICATION_FAILED", error.message ?: "Biometric authentication failed"), completion)
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
else -> complete(requestToken, failure("UNKNOWN_ACTION", "Unsupported Biometrics method"), completion)
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
private fun database(method: String, args: JSONArray): CraftNativeCapabilityResult {
|
|
243
|
+
val db = database ?: runCatching {
|
|
244
|
+
activity.openOrCreateDatabase("craft-native.sqlite", Context.MODE_PRIVATE, null)
|
|
245
|
+
}.getOrElse { return failure("DATABASE_ERROR", it.message ?: "Could not open the local database") }
|
|
246
|
+
.also { database = it }
|
|
247
|
+
return try {
|
|
248
|
+
when (method) {
|
|
249
|
+
"execute" -> {
|
|
250
|
+
val sql = args.optString(0).trim()
|
|
251
|
+
if (sql.isEmpty()) return failure("INVALID_ARGUMENT", "Database.execute needs a SQL statement")
|
|
252
|
+
execute(db, sql, args.optJSONArray(1) ?: JSONArray())
|
|
253
|
+
}
|
|
254
|
+
"query" -> {
|
|
255
|
+
val sql = args.optString(0).trim()
|
|
256
|
+
if (sql.isEmpty()) return failure("INVALID_ARGUMENT", "Database.query needs a SQL statement")
|
|
257
|
+
query(db, sql, args.optJSONArray(1) ?: JSONArray())
|
|
258
|
+
}
|
|
259
|
+
"beginTransaction" -> { db.beginTransaction(); success(true) }
|
|
260
|
+
"commit" -> {
|
|
261
|
+
if (!db.inTransaction()) failure("DATABASE_ERROR", "No transaction is active")
|
|
262
|
+
else { db.setTransactionSuccessful(); db.endTransaction(); success(true) }
|
|
263
|
+
}
|
|
264
|
+
"rollback" -> {
|
|
265
|
+
if (!db.inTransaction()) failure("DATABASE_ERROR", "No transaction is active")
|
|
266
|
+
else { db.endTransaction(); success(true) }
|
|
267
|
+
}
|
|
268
|
+
else -> failure("UNKNOWN_ACTION", "Unsupported Database method")
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
catch (error: Exception) {
|
|
272
|
+
failure("DATABASE_ERROR", error.message ?: "Database operation failed")
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
private fun execute(db: SQLiteDatabase, sql: String, params: JSONArray): CraftNativeCapabilityResult {
|
|
277
|
+
val statement = db.compileStatement(sql)
|
|
278
|
+
statement.use {
|
|
279
|
+
if (!bind(it, params)) return failure("INVALID_ARGUMENT", "Database parameters must be strings, numbers, booleans, or null")
|
|
280
|
+
val verb = sql.trimStart().substringBefore(' ').uppercase()
|
|
281
|
+
val insertId: Long
|
|
282
|
+
val rows: Int
|
|
283
|
+
when (verb) {
|
|
284
|
+
"INSERT", "REPLACE" -> { insertId = it.executeInsert(); rows = if (insertId < 0) 0 else 1 }
|
|
285
|
+
"UPDATE", "DELETE" -> { rows = it.executeUpdateDelete(); insertId = 0 }
|
|
286
|
+
else -> { it.execute(); rows = changes(db); insertId = lastInsertId(db) }
|
|
287
|
+
}
|
|
288
|
+
return success(JSONObject().put("rowsAffected", rows).put("lastInsertId", insertId))
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
private fun query(db: SQLiteDatabase, sql: String, params: JSONArray): CraftNativeCapabilityResult {
|
|
293
|
+
val cursor = db.rawQueryWithFactory({ _, driver, editTable, query ->
|
|
294
|
+
if (!bind(query, params)) throw IllegalArgumentException("Unsupported database parameter")
|
|
295
|
+
SQLiteCursor(driver, editTable, query)
|
|
296
|
+
}, sql, emptyArray<String>(), "")
|
|
297
|
+
cursor.use {
|
|
298
|
+
val rows = JSONArray()
|
|
299
|
+
while (it.moveToNext()) {
|
|
300
|
+
val row = JSONObject()
|
|
301
|
+
for (column in 0 until it.columnCount) {
|
|
302
|
+
row.put(it.getColumnName(column), when (it.getType(column)) {
|
|
303
|
+
Cursor.FIELD_TYPE_NULL -> JSONObject.NULL
|
|
304
|
+
Cursor.FIELD_TYPE_INTEGER -> it.getLong(column)
|
|
305
|
+
Cursor.FIELD_TYPE_FLOAT -> it.getDouble(column)
|
|
306
|
+
Cursor.FIELD_TYPE_BLOB -> Base64.encodeToString(it.getBlob(column), Base64.NO_WRAP)
|
|
307
|
+
else -> it.getString(column)
|
|
308
|
+
})
|
|
309
|
+
}
|
|
310
|
+
rows.put(row)
|
|
311
|
+
}
|
|
312
|
+
return success(rows)
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
private fun bind(statement: SQLiteStatement, params: JSONArray): Boolean {
|
|
317
|
+
for (offset in 0 until params.length()) {
|
|
318
|
+
val index = offset + 1
|
|
319
|
+
when (val value = params.opt(offset)) {
|
|
320
|
+
null, JSONObject.NULL -> statement.bindNull(index)
|
|
321
|
+
is String -> statement.bindString(index, value)
|
|
322
|
+
is Boolean -> statement.bindLong(index, if (value) 1 else 0)
|
|
323
|
+
is ByteArray -> statement.bindBlob(index, value)
|
|
324
|
+
is Float, is Double -> statement.bindDouble(index, (value as Number).toDouble())
|
|
325
|
+
is Number -> statement.bindLong(index, value.toLong())
|
|
326
|
+
else -> return false
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
return true
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
private fun bind(query: SQLiteQuery, params: JSONArray): Boolean {
|
|
333
|
+
for (offset in 0 until params.length()) {
|
|
334
|
+
val index = offset + 1
|
|
335
|
+
when (val value = params.opt(offset)) {
|
|
336
|
+
null, JSONObject.NULL -> query.bindNull(index)
|
|
337
|
+
is String -> query.bindString(index, value)
|
|
338
|
+
is Boolean -> query.bindLong(index, if (value) 1 else 0)
|
|
339
|
+
is Float, is Double -> query.bindDouble(index, (value as Number).toDouble())
|
|
340
|
+
is Number -> query.bindLong(index, value.toLong())
|
|
341
|
+
else -> return false
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
return true
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
private fun changes(db: SQLiteDatabase): Int = db.rawQuery("SELECT changes()", null).use {
|
|
348
|
+
if (it.moveToFirst()) it.getInt(0) else 0
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
private fun lastInsertId(db: SQLiteDatabase): Long = db.rawQuery("SELECT last_insert_rowid()", null).use {
|
|
352
|
+
if (it.moveToFirst()) it.getLong(0) else 0
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
private fun deepLinks(method: String): CraftNativeCapabilityResult {
|
|
356
|
+
if (!config.optBoolean("enableDeepLinks")) return failure("CAPABILITY_DISABLED", "Deep links are disabled")
|
|
357
|
+
if (method != "getInitialURL") return failure("UNKNOWN_ACTION", "Unsupported DeepLinks method")
|
|
358
|
+
return success(activity.consumeInitialDeepLink()?.let(activity::deepLinkData) ?: JSONObject.NULL)
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
private fun notifications(
|
|
362
|
+
requestToken: String,
|
|
363
|
+
method: String,
|
|
364
|
+
args: JSONArray,
|
|
365
|
+
completion: (CraftNativeCapabilityResult) -> Unit,
|
|
366
|
+
) {
|
|
367
|
+
if (!config.optBoolean("enableLocalNotifications")) {
|
|
368
|
+
complete(requestToken, failure("CAPABILITY_DISABLED", "Local notifications are disabled"), completion)
|
|
369
|
+
return
|
|
370
|
+
}
|
|
371
|
+
when (method) {
|
|
372
|
+
"schedule" -> complete(requestToken, schedule(args.optJSONObject(0)), completion)
|
|
373
|
+
"cancel" -> {
|
|
374
|
+
val id = validKey(args.opt(0))
|
|
375
|
+
complete(requestToken, if (id == null) failure("INVALID_ARGUMENT", "Notifications.cancel needs an id")
|
|
376
|
+
else { cancelNotification(id); success(true) }, completion)
|
|
377
|
+
}
|
|
378
|
+
"cancelAll" -> {
|
|
379
|
+
notificationStorage.all.keys.forEach(::cancelNotification)
|
|
380
|
+
(activity.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager).cancelAll()
|
|
381
|
+
complete(requestToken, success(true), completion)
|
|
382
|
+
}
|
|
383
|
+
"pending" -> complete(requestToken, success(JSONArray(notificationStorage.all.values.mapNotNull {
|
|
384
|
+
runCatching { JSONObject(it as String) }.getOrNull()
|
|
385
|
+
})), completion)
|
|
386
|
+
else -> complete(requestToken, failure("UNKNOWN_ACTION", "Unsupported Notifications method"), completion)
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
private fun schedule(value: JSONObject?): CraftNativeCapabilityResult {
|
|
391
|
+
val title = value?.optString("title").orEmpty()
|
|
392
|
+
if (title.isEmpty()) return failure("INVALID_ARGUMENT", "Notifications.schedule needs a title")
|
|
393
|
+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
|
|
394
|
+
ContextCompat.checkSelfPermission(activity, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
|
|
395
|
+
return failure("PERMISSION_DENIED", "Notification permission denied")
|
|
396
|
+
}
|
|
397
|
+
val id = validKey(value?.opt("id")) ?: UUID.randomUUID().toString()
|
|
398
|
+
val fireAt = System.currentTimeMillis() + value!!.optLong("delay", 0).coerceAtLeast(0)
|
|
399
|
+
val stored = JSONObject(value.toString()).put("id", id).put("fireAt", fireAt)
|
|
400
|
+
notificationStorage.edit().putString(id, stored.toString()).commit()
|
|
401
|
+
scheduleStored(stored)
|
|
402
|
+
return success(id)
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
private fun restoreNotifications() {
|
|
406
|
+
notificationStorage.all.values.forEach { raw ->
|
|
407
|
+
runCatching { JSONObject(raw as String) }.getOrNull()?.let(::scheduleStored)
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
private fun scheduleStored(value: JSONObject) {
|
|
412
|
+
val id = value.optString("id")
|
|
413
|
+
if (id.isEmpty()) return
|
|
414
|
+
val alarm = activity.getSystemService(Context.ALARM_SERVICE) as AlarmManager
|
|
415
|
+
alarm.setAndAllowWhileIdle(
|
|
416
|
+
AlarmManager.RTC_WAKEUP,
|
|
417
|
+
value.optLong("fireAt").coerceAtLeast(System.currentTimeMillis()),
|
|
418
|
+
notificationIntent(id),
|
|
419
|
+
)
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
private fun cancelNotification(id: String) {
|
|
423
|
+
val alarm = activity.getSystemService(Context.ALARM_SERVICE) as AlarmManager
|
|
424
|
+
val intent = notificationIntent(id)
|
|
425
|
+
alarm.cancel(intent)
|
|
426
|
+
intent.cancel()
|
|
427
|
+
notificationStorage.edit().remove(id).commit()
|
|
428
|
+
(activity.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager).cancel(id, 0)
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
private fun notificationIntent(id: String): PendingIntent = PendingIntent.getBroadcast(
|
|
432
|
+
activity,
|
|
433
|
+
0,
|
|
434
|
+
Intent(activity, CraftNativeNotificationReceiver::class.java)
|
|
435
|
+
.setData(Uri.Builder().scheme("craft-native").authority("notification").appendPath(id).build())
|
|
436
|
+
.putExtra("id", id),
|
|
437
|
+
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
|
438
|
+
)
|
|
439
|
+
|
|
440
|
+
private fun validKey(value: Any?): String? = (value as? String)?.takeIf {
|
|
441
|
+
it.isNotEmpty() && it.length <= 512 && !it.contains('\u0000')
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
private fun jsonString(value: Any?): String? = when (value) {
|
|
445
|
+
null, JSONObject.NULL -> "null"
|
|
446
|
+
is JSONObject, is JSONArray -> value.toString()
|
|
447
|
+
is String -> JSONObject.quote(value)
|
|
448
|
+
is Boolean -> value.toString()
|
|
449
|
+
is Number -> value.toString().takeUnless { it == "NaN" || it == "Infinity" || it == "-Infinity" }
|
|
450
|
+
else -> null
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
private fun success(data: Any?) = CraftNativeCapabilityResult(data = data)
|
|
454
|
+
private fun failure(code: String, message: String) = CraftNativeCapabilityResult(code = code, message = message)
|
|
455
|
+
}
|
|
@@ -44,15 +44,26 @@ internal class CraftNativeFlatList(context: Context) : RecyclerView(context) {
|
|
|
44
44
|
val item = getItem(position)
|
|
45
45
|
if (holder.identity != null && holder.identity != item.identity) release(holder)
|
|
46
46
|
val view = renderItem?.invoke(item.node, item.identity, holder.hostedView) ?: View(holder.host.context)
|
|
47
|
+
val renderedLayout = view.layoutParams
|
|
48
|
+
val hostedLayout = FrameLayout.LayoutParams(
|
|
49
|
+
renderedLayout?.width ?: LayoutParams.MATCH_PARENT,
|
|
50
|
+
renderedLayout?.height ?: LayoutParams.WRAP_CONTENT,
|
|
51
|
+
)
|
|
52
|
+
if (renderedLayout is ViewGroup.MarginLayoutParams) {
|
|
53
|
+
hostedLayout.setMargins(
|
|
54
|
+
renderedLayout.leftMargin,
|
|
55
|
+
renderedLayout.topMargin,
|
|
56
|
+
renderedLayout.rightMargin,
|
|
57
|
+
renderedLayout.bottomMargin,
|
|
58
|
+
)
|
|
59
|
+
}
|
|
47
60
|
holder.identity = item.identity
|
|
48
61
|
if (holder.hostedView !== view) {
|
|
49
62
|
(view.parent as? ViewGroup)?.removeView(view)
|
|
50
63
|
holder.host.removeAllViews()
|
|
51
|
-
holder.host.addView(view,
|
|
52
|
-
LayoutParams.MATCH_PARENT,
|
|
53
|
-
LayoutParams.WRAP_CONTENT,
|
|
54
|
-
))
|
|
64
|
+
holder.host.addView(view, hostedLayout)
|
|
55
65
|
}
|
|
66
|
+
else view.layoutParams = hostedLayout
|
|
56
67
|
}
|
|
57
68
|
|
|
58
69
|
override fun onViewRecycled(holder: Holder) {
|
|
@@ -74,6 +85,8 @@ internal class CraftNativeFlatList(context: Context) : RecyclerView(context) {
|
|
|
74
85
|
private var endReachedSignature: String? = null
|
|
75
86
|
private var horizontal = false
|
|
76
87
|
private var columns = 1
|
|
88
|
+
private var submissionGeneration = 0
|
|
89
|
+
private var isSubmittingList = false
|
|
77
90
|
|
|
78
91
|
init {
|
|
79
92
|
adapter = listAdapter
|
|
@@ -121,15 +134,23 @@ internal class CraftNativeFlatList(context: Context) : RecyclerView(context) {
|
|
|
121
134
|
val dataItems = next.filterNot { it.chrome }
|
|
122
135
|
val contentSignature = "${dataItems.size}:${dataItems.lastOrNull()?.identity.orEmpty()}"
|
|
123
136
|
if (contentSignature != endReachedSignature) endReachedSignature = null
|
|
137
|
+
val generation = ++submissionGeneration
|
|
138
|
+
isSubmittingList = true
|
|
124
139
|
listAdapter.submitList(next) {
|
|
140
|
+
if (generation != submissionGeneration) return@submitList
|
|
125
141
|
retainedState?.let { layoutManager?.onRestoreInstanceState(it) }
|
|
142
|
+
isSubmittingList = false
|
|
126
143
|
evaluateEndReached()
|
|
127
144
|
}
|
|
128
145
|
}
|
|
129
146
|
|
|
130
147
|
fun discardAll() {
|
|
131
148
|
listAdapter.currentList.forEach { recycleItem?.invoke(it.identity) }
|
|
132
|
-
|
|
149
|
+
val generation = ++submissionGeneration
|
|
150
|
+
isSubmittingList = true
|
|
151
|
+
listAdapter.submitList(emptyList()) {
|
|
152
|
+
if (generation == submissionGeneration) isSubmittingList = false
|
|
153
|
+
}
|
|
133
154
|
renderItem = null
|
|
134
155
|
recycleItem = null
|
|
135
156
|
endReached = null
|
|
@@ -149,6 +170,7 @@ internal class CraftNativeFlatList(context: Context) : RecyclerView(context) {
|
|
|
149
170
|
}
|
|
150
171
|
|
|
151
172
|
private fun evaluateEndReached() {
|
|
173
|
+
if (isSubmittingList) return
|
|
152
174
|
val manager = layoutManager as? LinearLayoutManager ?: return
|
|
153
175
|
val lastVisible = manager.findLastVisibleItemPosition()
|
|
154
176
|
val dataPositions = listAdapter.currentList.indices.filter { !listAdapter.currentList[it].chrome }
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
package {{PACKAGE_NAME}}
|
|
2
|
+
|
|
3
|
+
import android.Manifest
|
|
4
|
+
import android.app.NotificationChannel
|
|
5
|
+
import android.app.NotificationManager
|
|
6
|
+
import android.content.BroadcastReceiver
|
|
7
|
+
import android.content.Context
|
|
8
|
+
import android.content.Intent
|
|
9
|
+
import android.content.pm.PackageManager
|
|
10
|
+
import android.os.Build
|
|
11
|
+
import androidx.core.app.NotificationCompat
|
|
12
|
+
import androidx.core.content.ContextCompat
|
|
13
|
+
import org.json.JSONObject
|
|
14
|
+
|
|
15
|
+
/** Delivers native-runtime notifications even when the app process has exited. */
|
|
16
|
+
internal class CraftNativeNotificationReceiver : BroadcastReceiver() {
|
|
17
|
+
override fun onReceive(context: Context, intent: Intent) {
|
|
18
|
+
val id = intent.getStringExtra("id").orEmpty()
|
|
19
|
+
if (id.isEmpty()) return
|
|
20
|
+
val storage = context.getSharedPreferences("craft-native-notifications", Context.MODE_PRIVATE)
|
|
21
|
+
val value = storage.getString(id, null)?.let { runCatching { JSONObject(it) }.getOrNull() } ?: return
|
|
22
|
+
storage.edit().remove(id).commit()
|
|
23
|
+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
|
|
24
|
+
ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) return
|
|
25
|
+
|
|
26
|
+
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
|
27
|
+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
|
28
|
+
manager.createNotificationChannel(NotificationChannel(
|
|
29
|
+
"craft_notifications",
|
|
30
|
+
"Craft Notifications",
|
|
31
|
+
NotificationManager.IMPORTANCE_DEFAULT,
|
|
32
|
+
))
|
|
33
|
+
}
|
|
34
|
+
val notification = NotificationCompat.Builder(context, "craft_notifications")
|
|
35
|
+
.setSmallIcon(android.R.drawable.ic_dialog_info)
|
|
36
|
+
.setContentTitle(value.optString("title"))
|
|
37
|
+
.setContentText(value.optString("body"))
|
|
38
|
+
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
|
|
39
|
+
.setAutoCancel(true)
|
|
40
|
+
.build()
|
|
41
|
+
manager.notify(id, 0, notification)
|
|
42
|
+
}
|
|
43
|
+
}
|