craft-native 0.0.113 → 0.0.118

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.
@@ -415,6 +415,12 @@ async function init(options) {
415
415
  const mainActivityTemplate = readFileSync(join(TEMPLATES_DIR, config.renderer === "native" ? "MainActivityNative.kt.template" : "MainActivity.kt.template"), "utf-8");
416
416
  const mainActivity = mainActivityTemplate.replace(/\{\{PACKAGE_NAME\}\}/g, finalPackageName).replace(/\{\{APP_NAME\}\}/g, name);
417
417
  writeFileSync(join(output, "app/src/main/java", packagePath, "MainActivity.kt"), mainActivity);
418
+ if (config.renderer === "native") {
419
+ const mutationTemplate = readFileSync(join(TEMPLATES_DIR, "CraftNativeMutation.kt.template"), "utf-8");
420
+ const flatListTemplate = readFileSync(join(TEMPLATES_DIR, "CraftNativeFlatList.kt.template"), "utf-8");
421
+ writeFileSync(join(output, "app/src/main/java", packagePath, "CraftNativeMutation.kt"), mutationTemplate.replace(/\{\{PACKAGE_NAME\}\}/g, finalPackageName));
422
+ writeFileSync(join(output, "app/src/main/java", packagePath, "CraftNativeFlatList.kt"), flatListTemplate.replace(/\{\{PACKAGE_NAME\}\}/g, finalPackageName));
423
+ }
418
424
  const craftBridgeTemplate = readFileSync(join(TEMPLATES_DIR, "CraftBridge.kt.template"), "utf-8");
419
425
  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 {
420
426
  if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU
@@ -5,6 +5,14 @@
5
5
  {{PERMISSIONS}}
6
6
  {{HEALTH_CONNECT_QUERIES}}
7
7
 
8
+ <!-- craft.speech: from Android 11 an app cannot see, and so cannot bind,
9
+ a text-to-speech engine it has not declared it will look for. -->
10
+ <queries>
11
+ <intent>
12
+ <action android:name="android.intent.action.TTS_SERVICE" />
13
+ </intent>
14
+ </queries>
15
+
8
16
  <!-- Camera feature (optional) -->
9
17
  <uses-feature android:name="android.hardware.camera" android:required="false" />
10
18
  <uses-feature android:name="android.hardware.camera.autofocus" android:required="false" />
@@ -36,6 +36,11 @@ import android.provider.Settings
36
36
  import android.speech.RecognitionListener
37
37
  import android.speech.RecognizerIntent
38
38
  import android.speech.SpeechRecognizer
39
+ import android.speech.tts.TextToSpeech
40
+ import android.speech.tts.UtteranceProgressListener
41
+ import android.media.AudioAttributes
42
+ import android.media.AudioFocusRequest
43
+ import android.media.AudioManager
39
44
  import android.util.DisplayMetrics
40
45
  import android.webkit.JavascriptInterface
41
46
  import android.webkit.WebView
@@ -199,6 +204,17 @@ internal object CraftPermissionPolicy {
199
204
  isGranted(Manifest.permission.ACCESS_COARSE_LOCATION)
200
205
  }
201
206
 
207
+ private data class SpeechRequest(
208
+ val text: String,
209
+ val rate: Float,
210
+ val language: String?,
211
+ val callbackId: Int,
212
+ val generation: Int
213
+ )
214
+
215
+ /** The navigation it belongs to, so a page that has gone hears nothing. */
216
+ private data class PendingUtterance(val callbackId: Int, val generation: Int)
217
+
202
218
  private data class PendingPermissionRequest(
203
219
  val callbackId: Int,
204
220
  val permission: String,
@@ -279,6 +295,19 @@ class CraftBridge(
279
295
  // Keep awake
280
296
  private var isKeepingAwake = false
281
297
 
298
+ // Speech synthesis, all of it touched on the main thread only. The engine
299
+ // is bound on the first speak rather than here, so an app that never
300
+ // speaks never starts a TTS service. Requests made while it binds wait in
301
+ // queuedSpeech; each utterance handed to it waits in pendingUtterances,
302
+ // under the id the engine reports back, for the page call it answers.
303
+ private var textToSpeech: TextToSpeech? = null
304
+ private var textToSpeechReady = false
305
+ private val queuedSpeech = mutableListOf<SpeechRequest>()
306
+ private val pendingUtterances = linkedMapOf<String, PendingUtterance>()
307
+ private var nextUtteranceId = 0L
308
+ private var speechFocusRequest: AudioFocusRequest? = null
309
+ private val speechHandler = Handler(Looper.getMainLooper())
310
+
282
311
  // Billing clients for in-app purchases
283
312
  private var productBillingClient: BillingClient? = null
284
313
  private var restoreBillingClient: BillingClient? = null
@@ -388,6 +417,7 @@ class CraftBridge(
388
417
  networkStatus: true,
389
418
  appReview: true,
390
419
  flashlight: {{ENABLE_CAMERA}} && ${hasFlashlight()},
420
+ speech: true,
391
421
  openURL: true,
392
422
  vibrationPattern: true,
393
423
  appState: true,
@@ -1642,6 +1672,66 @@ class CraftBridge(
1642
1672
  return Promise.resolve().then(function() { CraftAndroid.openPermissionSettings(); });
1643
1673
  }
1644
1674
  };
1675
+ // Speech synthesis. speak() settles when the utterance ends:
1676
+ // true when it was spoken, false when something stopped it
1677
+ // or the device has no engine to speak with. No timeout, as
1678
+ // on iOS: a long sentence at a slow rate outlasts any
1679
+ // deadline that would be fair to a short cue.
1680
+ var speechRequestSequence = 0;
1681
+ var speechRequests = Object.create(null);
1682
+ var speechRuntimeClosed = false;
1683
+ window.__craftSpeechResult = function(id, spoken) {
1684
+ if (speechRuntimeClosed) return;
1685
+ var pending = speechRequests[id];
1686
+ if (!pending) return;
1687
+ delete speechRequests[id];
1688
+ pending.resolve(spoken === true);
1689
+ };
1690
+ window.__craftSpeechError = function(id, message, code) {
1691
+ if (speechRuntimeClosed) return;
1692
+ var pending = speechRequests[id];
1693
+ if (!pending) return;
1694
+ delete speechRequests[id];
1695
+ var error = new Error(message);
1696
+ error.code = code || 'CRAFT_ERROR';
1697
+ error.bridge = true;
1698
+ pending.reject(error);
1699
+ };
1700
+ window.__craftRejectSpeechRequests = function(message) {
1701
+ speechRuntimeClosed = true;
1702
+ Object.keys(speechRequests).forEach(function(id) {
1703
+ var pending = speechRequests[id];
1704
+ delete speechRequests[id];
1705
+ pending.reject(new Error(message || 'Android bridge closed'));
1706
+ });
1707
+ };
1708
+ craft.speech = {
1709
+ speak: function(text, options) {
1710
+ if (speechRuntimeClosed) {
1711
+ return Promise.reject(new Error('Android bridge is closed'));
1712
+ }
1713
+ options = options || {};
1714
+ var settings = JSON.stringify({
1715
+ rate: typeof options.rate === 'number' ? options.rate : null,
1716
+ language: typeof options.language === 'string' ? options.language : null,
1717
+ interrupt: options.interrupt !== false
1718
+ });
1719
+ return new Promise(function(resolve, reject) {
1720
+ speechRequestSequence += 1;
1721
+ var id = speechRequestSequence;
1722
+ speechRequests[id] = {resolve: resolve, reject: reject};
1723
+ try {
1724
+ CraftAndroid.speak(text == null ? '' : String(text), settings, id);
1725
+ } catch (error) {
1726
+ delete speechRequests[id];
1727
+ reject(error);
1728
+ }
1729
+ });
1730
+ },
1731
+ stop: function() {
1732
+ return Promise.resolve().then(function() { CraftAndroid.stopSpeaking(); });
1733
+ }
1734
+ };
1645
1735
  craft.location = {
1646
1736
  getCurrentPosition: craft.getCurrentPosition,
1647
1737
  watchPosition: craft.watchPosition,
@@ -3556,6 +3646,189 @@ class CraftBridge(
3556
3646
  return true
3557
3647
  }
3558
3648
 
3649
+ // ==================== Speech Synthesis ====================
3650
+
3651
+ /**
3652
+ * Say [text], and answer the page's call [callbackId] once it has been
3653
+ * said (`true`) or stopped (`false`).
3654
+ *
3655
+ * The audio is marked as navigation guidance and asks for transient,
3656
+ * may-duck focus, which is what makes the music dip under the cue rather
3657
+ * than stop, and come back once [abandonSpeechFocus] runs. The WebView's
3658
+ * own `speechSynthesis` has no say in either.
3659
+ */
3660
+ @JavascriptInterface
3661
+ fun speak(text: String?, optionsJson: String?, callbackId: Int) {
3662
+ activity.runOnUiThread {
3663
+ if (closed) return@runOnUiThread
3664
+ if (text.isNullOrBlank()) {
3665
+ rejectSpeech(callbackId, "speak needs some text to say", "INVALID_ARGUMENT")
3666
+ return@runOnUiThread
3667
+ }
3668
+ val options = runCatching { JSONObject(optionsJson ?: "{}") }.getOrElse { JSONObject() }
3669
+ val rate = if (options.isNull("rate")) 1.0 else options.optDouble("rate", 1.0)
3670
+ val request = SpeechRequest(
3671
+ text = text,
3672
+ // 1 is normal on both scales, so this only bounds it.
3673
+ rate = (if (rate.isFinite()) rate else 1.0).coerceIn(0.5, 2.0).toFloat(),
3674
+ language = if (options.isNull("language")) null else options.optString("language").ifBlank { null },
3675
+ callbackId = callbackId,
3676
+ generation = navigationGeneration
3677
+ )
3678
+ if (options.optBoolean("interrupt", true)) cancelPendingSpeech()
3679
+
3680
+ val engine = textToSpeech
3681
+ if (engine != null && textToSpeechReady) {
3682
+ startUtterance(engine, request)
3683
+ } else {
3684
+ queuedSpeech.add(request)
3685
+ bindTextToSpeech()
3686
+ }
3687
+ }
3688
+ }
3689
+
3690
+ @JavascriptInterface
3691
+ fun stopSpeaking(): Boolean {
3692
+ activity.runOnUiThread {
3693
+ if (closed) return@runOnUiThread
3694
+ cancelPendingSpeech()
3695
+ abandonSpeechFocus()
3696
+ }
3697
+ return true
3698
+ }
3699
+
3700
+ private val speechAudioAttributes: AudioAttributes by lazy {
3701
+ AudioAttributes.Builder()
3702
+ .setUsage(AudioAttributes.USAGE_ASSISTANCE_NAVIGATION_GUIDANCE)
3703
+ .setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
3704
+ .build()
3705
+ }
3706
+
3707
+ /** Main thread. Binding takes a moment, and finishes in the listener. */
3708
+ private fun bindTextToSpeech() {
3709
+ if (textToSpeech != null) return
3710
+ var engine: TextToSpeech? = null
3711
+ // Posted, never run inline: an engine that cannot bind reports so
3712
+ // from inside its own constructor, before `engine` has been assigned,
3713
+ // and runOnUiThread on the main thread would run it right there.
3714
+ engine = TextToSpeech(activity.applicationContext) { status ->
3715
+ speechHandler.post { onTextToSpeechBound(engine, status == TextToSpeech.SUCCESS) }
3716
+ }
3717
+ textToSpeech = engine
3718
+ }
3719
+
3720
+ private fun onTextToSpeechBound(engine: TextToSpeech?, succeeded: Boolean) {
3721
+ // An engine that close() or a failed earlier bind has since replaced.
3722
+ if (closed || engine == null || engine !== textToSpeech) return
3723
+ val waiting = queuedSpeech.toList()
3724
+ queuedSpeech.clear()
3725
+ if (!succeeded) {
3726
+ // No engine, or none that would start. Each call settles as
3727
+ // "nothing was said", and the next speak tries binding again.
3728
+ runCatching { engine.shutdown() }
3729
+ textToSpeech = null
3730
+ waiting.forEach { settleSpeech(it.callbackId, it.generation, false) }
3731
+ return
3732
+ }
3733
+ engine.setAudioAttributes(speechAudioAttributes)
3734
+ engine.setOnUtteranceProgressListener(object : UtteranceProgressListener() {
3735
+ override fun onStart(utteranceId: String?) {}
3736
+ override fun onDone(utteranceId: String?) {
3737
+ activity.runOnUiThread { utteranceEnded(utteranceId, true) }
3738
+ }
3739
+ @Deprecated("Deprecated in Java")
3740
+ override fun onError(utteranceId: String?) {
3741
+ activity.runOnUiThread { utteranceEnded(utteranceId, false) }
3742
+ }
3743
+ override fun onError(utteranceId: String?, errorCode: Int) {
3744
+ activity.runOnUiThread { utteranceEnded(utteranceId, false) }
3745
+ }
3746
+ override fun onStop(utteranceId: String?, interrupted: Boolean) {
3747
+ activity.runOnUiThread { utteranceEnded(utteranceId, false) }
3748
+ }
3749
+ })
3750
+ textToSpeechReady = true
3751
+ waiting.forEach { startUtterance(engine, it) }
3752
+ }
3753
+
3754
+ private fun startUtterance(engine: TextToSpeech, request: SpeechRequest) {
3755
+ if (!requestSpeechFocus()) {
3756
+ // A call, or something else that may not be talked over.
3757
+ settleSpeech(request.callbackId, request.generation, false)
3758
+ return
3759
+ }
3760
+ // Both apply to the speak calls after them, so each utterance sets
3761
+ // its own rather than inheriting the last one's.
3762
+ engine.setSpeechRate(request.rate)
3763
+ val locale = request.language?.let { Locale.forLanguageTag(it) }
3764
+ if (locale == null || engine.setLanguage(locale) < TextToSpeech.LANG_AVAILABLE) {
3765
+ engine.setLanguage(Locale.getDefault())
3766
+ }
3767
+
3768
+ nextUtteranceId += 1
3769
+ val utteranceId = "craft-speech-$nextUtteranceId"
3770
+ pendingUtterances[utteranceId] = PendingUtterance(request.callbackId, request.generation)
3771
+ if (engine.speak(request.text, TextToSpeech.QUEUE_ADD, null, utteranceId) != TextToSpeech.SUCCESS) {
3772
+ pendingUtterances.remove(utteranceId)
3773
+ settleSpeech(request.callbackId, request.generation, false)
3774
+ if (pendingUtterances.isEmpty()) abandonSpeechFocus()
3775
+ }
3776
+ }
3777
+
3778
+ private fun utteranceEnded(utteranceId: String?, spoken: Boolean) {
3779
+ val pending = utteranceId?.let { pendingUtterances.remove(it) }
3780
+ if (pending != null) settleSpeech(pending.callbackId, pending.generation, spoken)
3781
+ if (pendingUtterances.isEmpty() && queuedSpeech.isEmpty()) abandonSpeechFocus()
3782
+ }
3783
+
3784
+ /**
3785
+ * Stop the engine and settle every call waiting on it, false.
3786
+ *
3787
+ * Settled here rather than in `onStop`, so that a request still waiting
3788
+ * for the engine to bind, which the engine has never heard of, settles
3789
+ * too.
3790
+ */
3791
+ private fun cancelPendingSpeech() {
3792
+ val waiting = pendingUtterances.values.toList() +
3793
+ queuedSpeech.map { PendingUtterance(it.callbackId, it.generation) }
3794
+ pendingUtterances.clear()
3795
+ queuedSpeech.clear()
3796
+ if (textToSpeechReady) runCatching { textToSpeech?.stop() }
3797
+ waiting.forEach { settleSpeech(it.callbackId, it.generation, false) }
3798
+ }
3799
+
3800
+ private fun requestSpeechFocus(): Boolean {
3801
+ if (speechFocusRequest != null) return true
3802
+ val manager = activity.getSystemService(Context.AUDIO_SERVICE) as? AudioManager ?: return true
3803
+ val request = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_MAY_DUCK)
3804
+ .setAudioAttributes(speechAudioAttributes)
3805
+ .build()
3806
+ if (manager.requestAudioFocus(request) == AudioManager.AUDIOFOCUS_REQUEST_FAILED) return false
3807
+ speechFocusRequest = request
3808
+ return true
3809
+ }
3810
+
3811
+ private fun abandonSpeechFocus() {
3812
+ val request = speechFocusRequest ?: return
3813
+ speechFocusRequest = null
3814
+ val manager = activity.getSystemService(Context.AUDIO_SERVICE) as? AudioManager ?: return
3815
+ runCatching { manager.abandonAudioFocusRequest(request) }
3816
+ }
3817
+
3818
+ /** An answer for a page that has since navigated away is dropped. */
3819
+ private fun settleSpeech(callbackId: Int, generation: Int, spoken: Boolean) {
3820
+ if (generation != navigationGeneration) return
3821
+ evaluateJavascriptUnlessClosed(
3822
+ "window.__craftSpeechResult && window.__craftSpeechResult($callbackId, $spoken)"
3823
+ )
3824
+ }
3825
+
3826
+ private fun rejectSpeech(callbackId: Int, message: String, code: String) {
3827
+ evaluateJavascriptUnlessClosed(
3828
+ "window.__craftSpeechError && window.__craftSpeechError($callbackId, ${jsQuote(message)}, ${jsQuote(code)})"
3829
+ )
3830
+ }
3831
+
3559
3832
  // ==================== Orientation Lock ====================
3560
3833
 
3561
3834
  @JavascriptInterface
@@ -4077,7 +4350,8 @@ class CraftBridge(
4077
4350
  runCatching {
4078
4351
  webView.evaluateJavascript(
4079
4352
  "window.__craftRejectPendingPromises && window.__craftRejectPendingPromises('Android bridge closed');" +
4080
- "window.__craftRejectPermissionRequests && window.__craftRejectPermissionRequests('Android bridge closed')",
4353
+ "window.__craftRejectPermissionRequests && window.__craftRejectPermissionRequests('Android bridge closed');" +
4354
+ "window.__craftRejectSpeechRequests && window.__craftRejectSpeechRequests('Android bridge closed')",
4081
4355
  null
4082
4356
  )
4083
4357
  }
@@ -4144,6 +4418,16 @@ class CraftBridge(
4144
4418
  runCatching { database?.close() }
4145
4419
  database = null
4146
4420
 
4421
+ // The page's calls were rejected above; this only silences the
4422
+ // engine and gives the music back its volume.
4423
+ queuedSpeech.clear()
4424
+ pendingUtterances.clear()
4425
+ runCatching { textToSpeech?.stop() }
4426
+ runCatching { textToSpeech?.shutdown() }
4427
+ textToSpeech = null
4428
+ textToSpeechReady = false
4429
+ abandonSpeechFocus()
4430
+
4147
4431
  if (isFlashlightOn) setFlashlight(false)
4148
4432
  activity.window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
4149
4433
  isKeepingAwake = false
@@ -0,0 +1,181 @@
1
+ package {{PACKAGE_NAME}}
2
+
3
+ import android.content.Context
4
+ import android.view.View
5
+ import android.view.ViewGroup
6
+ import android.widget.FrameLayout
7
+ import androidx.recyclerview.widget.DiffUtil
8
+ import androidx.recyclerview.widget.GridLayoutManager
9
+ import androidx.recyclerview.widget.LinearLayoutManager
10
+ import androidx.recyclerview.widget.ListAdapter
11
+ import androidx.recyclerview.widget.RecyclerView
12
+ import org.json.JSONObject
13
+ import kotlin.math.ceil
14
+
15
+ internal typealias CraftNativeRenderItem = (node: JSONObject, identity: String, previous: View?) -> View
16
+
17
+ /** A stable-key RecyclerView host for stx-native FlatList nodes. */
18
+ internal class CraftNativeFlatList(context: Context) : RecyclerView(context) {
19
+ private data class Item(
20
+ val identity: String,
21
+ val node: JSONObject,
22
+ val signature: String,
23
+ val chrome: Boolean,
24
+ )
25
+
26
+ private class Holder(val host: FrameLayout) : ViewHolder(host) {
27
+ var identity: String? = null
28
+ val hostedView: View? get() = host.getChildAt(0)
29
+ }
30
+
31
+ private val listAdapter = object : ListAdapter<Item, Holder>(object : DiffUtil.ItemCallback<Item>() {
32
+ override fun areItemsTheSame(oldItem: Item, newItem: Item) = oldItem.identity == newItem.identity
33
+ override fun areContentsTheSame(oldItem: Item, newItem: Item) = oldItem.signature == newItem.signature
34
+ }) {
35
+ init { setHasStableIds(true) }
36
+
37
+ override fun getItemId(position: Int): Long = stableId(getItem(position).identity)
38
+
39
+ override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = Holder(FrameLayout(parent.context).apply {
40
+ layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)
41
+ })
42
+
43
+ override fun onBindViewHolder(holder: Holder, position: Int) {
44
+ val item = getItem(position)
45
+ if (holder.identity != null && holder.identity != item.identity) release(holder)
46
+ val view = renderItem?.invoke(item.node, item.identity, holder.hostedView) ?: View(holder.host.context)
47
+ holder.identity = item.identity
48
+ if (holder.hostedView !== view) {
49
+ (view.parent as? ViewGroup)?.removeView(view)
50
+ holder.host.removeAllViews()
51
+ holder.host.addView(view, FrameLayout.LayoutParams(
52
+ LayoutParams.MATCH_PARENT,
53
+ LayoutParams.WRAP_CONTENT,
54
+ ))
55
+ }
56
+ }
57
+
58
+ override fun onViewRecycled(holder: Holder) {
59
+ release(holder)
60
+ super.onViewRecycled(holder)
61
+ }
62
+
63
+ private fun release(holder: Holder) {
64
+ holder.identity?.let { recycleItem?.invoke(it) }
65
+ holder.host.removeAllViews()
66
+ holder.identity = null
67
+ }
68
+ }
69
+
70
+ private var renderItem: CraftNativeRenderItem? = null
71
+ private var recycleItem: ((String) -> Unit)? = null
72
+ private var endReached: (() -> Unit)? = null
73
+ private var threshold = 0.1
74
+ private var endReachedSignature: String? = null
75
+ private var horizontal = false
76
+ private var columns = 1
77
+
78
+ init {
79
+ adapter = listAdapter
80
+ itemAnimator = null
81
+ addOnScrollListener(object : OnScrollListener() {
82
+ override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) = evaluateEndReached()
83
+ })
84
+ }
85
+
86
+ fun apply(
87
+ nodes: List<JSONObject>,
88
+ horizontal: Boolean,
89
+ columns: Int,
90
+ inverted: Boolean,
91
+ endReachedThreshold: Double,
92
+ renderItem: CraftNativeRenderItem,
93
+ recycleItem: (String) -> Unit,
94
+ endReached: (() -> Unit)?,
95
+ ) {
96
+ this.renderItem = renderItem
97
+ this.recycleItem = recycleItem
98
+ this.endReached = endReached
99
+ threshold = endReachedThreshold.coerceAtLeast(0.0)
100
+
101
+ val nextColumns = columns.coerceAtLeast(1)
102
+ val layoutChanged = this.horizontal != horizontal || this.columns != nextColumns
103
+ val retainedState = if (layoutChanged) null else layoutManager?.onSaveInstanceState()
104
+ this.horizontal = horizontal
105
+ this.columns = nextColumns
106
+ if (layoutChanged || layoutManager == null) configureLayoutManager()
107
+
108
+ val seen = mutableMapOf<String, Int>()
109
+ val next = nodes.mapIndexed { index, node ->
110
+ val base = identity(node) ?: "index:$index"
111
+ val occurrence = seen.getOrDefault(base, 0)
112
+ seen[base] = occurrence + 1
113
+ val unique = if (occurrence == 0) base else "$base#$occurrence"
114
+ val role = node.optJSONObject("props")?.optString("listRole").orEmpty()
115
+ Item(unique, node, node.toString(), role.isNotBlank() && role != "item")
116
+ }.let { if (inverted) it.reversed() else it }
117
+
118
+ val oldIds = listAdapter.currentList.mapTo(mutableSetOf()) { it.identity }
119
+ val nextIds = next.mapTo(mutableSetOf()) { it.identity }
120
+ (oldIds - nextIds).forEach(recycleItem)
121
+ val dataItems = next.filterNot { it.chrome }
122
+ val contentSignature = "${dataItems.size}:${dataItems.lastOrNull()?.identity.orEmpty()}"
123
+ if (contentSignature != endReachedSignature) endReachedSignature = null
124
+ listAdapter.submitList(next) {
125
+ retainedState?.let { layoutManager?.onRestoreInstanceState(it) }
126
+ evaluateEndReached()
127
+ }
128
+ }
129
+
130
+ fun discardAll() {
131
+ listAdapter.currentList.forEach { recycleItem?.invoke(it.identity) }
132
+ listAdapter.submitList(emptyList())
133
+ renderItem = null
134
+ recycleItem = null
135
+ endReached = null
136
+ }
137
+
138
+ private fun configureLayoutManager() {
139
+ layoutManager = when {
140
+ horizontal -> LinearLayoutManager(context, HORIZONTAL, false)
141
+ columns > 1 -> GridLayoutManager(context, columns).also { grid ->
142
+ grid.spanSizeLookup = object : GridLayoutManager.SpanSizeLookup() {
143
+ override fun getSpanSize(position: Int): Int =
144
+ if (listAdapter.currentList.getOrNull(position)?.chrome == true) columns else 1
145
+ }
146
+ }
147
+ else -> LinearLayoutManager(context)
148
+ }
149
+ }
150
+
151
+ private fun evaluateEndReached() {
152
+ val manager = layoutManager as? LinearLayoutManager ?: return
153
+ val lastVisible = manager.findLastVisibleItemPosition()
154
+ val dataPositions = listAdapter.currentList.indices.filter { !listAdapter.currentList[it].chrome }
155
+ val lastDataOrdinal = dataPositions.indexOfLast { it <= lastVisible }
156
+ if (dataPositions.isEmpty() || lastDataOrdinal < 0 || endReached == null) return
157
+ val remaining = dataPositions.size - lastDataOrdinal - 1
158
+ val thresholdItems = maxOf(1, ceil(dataPositions.size * threshold).toInt())
159
+ if (remaining > thresholdItems) return
160
+ val lastIdentity = listAdapter.currentList[dataPositions.last()].identity
161
+ val signature = "${dataPositions.size}:$lastIdentity"
162
+ if (endReachedSignature == signature) return
163
+ endReachedSignature = signature
164
+ endReached?.invoke()
165
+ }
166
+
167
+ private fun identity(node: JSONObject): String? {
168
+ val props = node.optJSONObject("props") ?: JSONObject()
169
+ return listOf(node.optString("id"), node.optString("key"), props.optString("key"), props.optString("testID"))
170
+ .firstOrNull { it.isNotBlank() }
171
+ }
172
+
173
+ private fun stableId(value: String): Long {
174
+ var hash = -0x340d631b7bdddcdbL
175
+ for (byte in value.encodeToByteArray()) {
176
+ hash = hash xor (byte.toLong() and 0xff)
177
+ hash *= 0x100000001b3L
178
+ }
179
+ return hash
180
+ }
181
+ }