craft-native 0.0.115 → 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.
@@ -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, FrameLayout.LayoutParams(
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
- listAdapter.submitList(emptyList())
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
+ }