@craft-native/android 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.
@@ -3,13 +3,17 @@ package {{PACKAGE_NAME}}
3
3
  import android.content.ClipData
4
4
  import android.content.ClipboardManager
5
5
  import android.content.Context
6
+ import android.content.Intent
6
7
  import android.graphics.BitmapFactory
7
8
  import android.graphics.Color
8
9
  import android.graphics.Paint
9
10
  import android.graphics.Typeface
10
11
  import android.graphics.drawable.GradientDrawable
12
+ import android.net.Uri
11
13
  import android.os.Build
12
14
  import android.os.Bundle
15
+ import android.os.Handler
16
+ import android.os.Looper
13
17
  import android.os.VibrationEffect
14
18
  import android.os.Vibrator
15
19
  import android.text.Editable
@@ -39,6 +43,7 @@ import androidx.javascriptengine.JavaScriptSandbox
39
43
  import androidx.javascriptengine.Message
40
44
  import androidx.javascriptengine.MessagePort
41
45
  import androidx.javascriptengine.MessagePortClient
46
+ import com.google.common.util.concurrent.ListenableFuture
42
47
  import org.json.JSONArray
43
48
  import org.json.JSONObject
44
49
  import java.net.URL
@@ -46,6 +51,8 @@ import java.util.concurrent.Executors
46
51
  import java.util.concurrent.Future
47
52
  import java.util.UUID
48
53
 
54
+ private const val CRAFT_NATIVE_CAPABILITY_TIMEOUT_MS = 30_000L
55
+
49
56
  private class CraftNativeScrollView(context: Context) : FrameLayout(context) {
50
57
  val content = LinearLayout(context)
51
58
  private val vertical = ScrollView(context)
@@ -87,6 +94,9 @@ class MainActivity : AppCompatActivity() {
87
94
  val flatListOwners = mutableMapOf<String, String>()
88
95
  val flatListOwnerByView = mutableMapOf<CraftNativeFlatList, String>()
89
96
  val mutations = CraftNativeMutationDocument()
97
+ val capabilityScope = UUID.randomUUID().toString()
98
+ val pendingCapabilities = mutableSetOf<String>()
99
+ val capabilityTimeouts = mutableMapOf<String, Runnable>()
90
100
  var active = true
91
101
 
92
102
  fun close() {
@@ -100,7 +110,16 @@ class MainActivity : AppCompatActivity() {
100
110
  private var sandbox: JavaScriptSandbox? = null
101
111
  private val screens = mutableListOf<Screen>()
102
112
  private var bundle = ""
113
+ private lateinit var config: JSONObject
114
+ private lateinit var capabilities: CraftNativeCapabilities
115
+ private val capabilityHandler = Handler(Looper.getMainLooper())
103
116
  private var hapticsEnabled = false
117
+ private var clipboardEnabled = false
118
+ internal var nativeAppState = "inactive"
119
+ private set
120
+ private var deliveredInitialDeepLink = false
121
+ private var initialDeepLink: Uri? = null
122
+ private var initialDeepLinkClaimed = false
104
123
  private var defaultTextColor = Color.WHITE
105
124
  private var useMessagePorts = false
106
125
  private val imageExecutor = Executors.newFixedThreadPool(2)
@@ -110,14 +129,19 @@ class MainActivity : AppCompatActivity() {
110
129
 
111
130
  override fun onCreate(savedInstanceState: Bundle?) {
112
131
  super.onCreate(savedInstanceState)
132
+ initialDeepLink = savedInstanceState?.getString("craft.initialDeepLink")?.let(Uri::parse) ?: intent?.data
133
+ initialDeepLinkClaimed = savedInstanceState?.getBoolean("craft.initialDeepLinkClaimed", false) ?: false
134
+ deliveredInitialDeepLink = savedInstanceState?.getBoolean("craft.deliveredInitialDeepLink", false) ?: false
113
135
  setContentView(R.layout.activity_main)
114
136
  root = findViewById(R.id.native_root)
115
- val config = runCatching {
137
+ config = runCatching {
116
138
  assets.open("craft.config.json").bufferedReader().use {
117
139
  JSONObject(it.readText())
118
140
  }
119
141
  }.getOrDefault(JSONObject())
120
142
  hapticsEnabled = config.optBoolean("enableHaptics", false)
143
+ clipboardEnabled = config.optBoolean("enableClipboard", false)
144
+ capabilities = CraftNativeCapabilities(this, config)
121
145
  val background = color(config.optString("backgroundColor", "#1a1a2e"), Color.BLACK)
122
146
  root.setBackgroundColor(background)
123
147
  defaultTextColor = if (Color.luminance(background) < 0.5) Color.WHITE else Color.BLACK
@@ -131,29 +155,45 @@ class MainActivity : AppCompatActivity() {
131
155
  showError("Native STX requires Android JavaScriptSandbox, which is unavailable on this device.")
132
156
  return
133
157
  }
134
- val connection = JavaScriptSandbox.createConnectedInstanceAsync(this)
135
- connection.addListener({
136
- try {
137
- val engine = connection.get()
138
- if (isDestroyed) {
139
- engine.close()
140
- return@addListener
141
- }
142
- useMessagePorts = engine.isFeatureSupported(JavaScriptSandbox.JS_FEATURE_MESSAGE_PORTS)
143
- && engine.isFeatureSupported(JavaScriptSandbox.JS_FEATURE_PROMISE_RETURN)
144
- if (!useMessagePorts && !engine.isFeatureSupported(JavaScriptSandbox.JS_FEATURE_CONSOLE_MESSAGING)) {
145
- engine.close()
146
- showError("Native STX requires JavaScript message ports or console messaging, unavailable on this device.")
147
- return@addListener
148
- }
149
- Log.i(TAG, "Native STX bridge: ${if (useMessagePorts) "message ports" else "console callback"}")
150
- sandbox = engine
151
- openScreen(null)
152
- } catch (error: Exception) {
153
- Log.e(TAG, "JavaScriptSandbox connection failed", error)
154
- showError("Native STX JavaScript engine could not start: ${error.message}")
158
+ val connection = synchronized(SANDBOX_LOCK) {
159
+ sharedSandboxConnection ?: sharedSandbox?.let { existing ->
160
+ sandbox = existing
161
+ null
162
+ } ?: JavaScriptSandbox.createConnectedInstanceAsync(applicationContext).also {
163
+ sharedSandboxConnection = it
155
164
  }
156
- }, ContextCompat.getMainExecutor(this))
165
+ }
166
+ if (connection == null) {
167
+ sandbox?.let(::attachSandbox)
168
+ } else {
169
+ connection.addListener({
170
+ try {
171
+ val engine = connection.get()
172
+ synchronized(SANDBOX_LOCK) {
173
+ sharedSandbox = engine
174
+ }
175
+ if (!isDestroyed) {
176
+ sandbox = engine
177
+ attachSandbox(engine)
178
+ }
179
+ } catch (error: Exception) {
180
+ synchronized(SANDBOX_LOCK) { sharedSandboxConnection = null }
181
+ Log.e(TAG, "JavaScriptSandbox connection failed", error)
182
+ if (!isDestroyed) showError("Native STX JavaScript engine could not start: ${error.message}")
183
+ }
184
+ }, ContextCompat.getMainExecutor(this))
185
+ }
186
+ }
187
+
188
+ private fun attachSandbox(engine: JavaScriptSandbox) {
189
+ useMessagePorts = engine.isFeatureSupported(JavaScriptSandbox.JS_FEATURE_MESSAGE_PORTS)
190
+ && engine.isFeatureSupported(JavaScriptSandbox.JS_FEATURE_PROMISE_RETURN)
191
+ if (!useMessagePorts && !engine.isFeatureSupported(JavaScriptSandbox.JS_FEATURE_CONSOLE_MESSAGING)) {
192
+ showError("Native STX requires JavaScript message ports or console messaging, unavailable on this device.")
193
+ return
194
+ }
195
+ Log.i(TAG, "Native STX bridge: ${if (useMessagePorts) "message ports" else "console callback"}")
196
+ openScreen(null)
157
197
  }
158
198
 
159
199
  private fun openScreen(route: String?, params: JSONObject = JSONObject()) {
@@ -170,13 +210,13 @@ class MainActivity : AppCompatActivity() {
170
210
  val port = if (useMessagePorts) {
171
211
  isolate.createMessageChannel("craft", executor, MessagePortClient { message ->
172
212
  val current = screen ?: return@MessagePortClient
173
- if (current.active && message.type == Message.TYPE_STRING) receive(current, message.string)
213
+ if (current.active && message.type == Message.TYPE_STRING) receiveSafely(current, message.string)
174
214
  })
175
215
  } else {
176
216
  isolate.setConsoleCallback(executor, JavaScriptConsoleCallback { entry ->
177
217
  val current = screen ?: return@JavaScriptConsoleCallback
178
218
  if (current.active && entry.message.startsWith(prefix)) {
179
- receive(current, entry.message.removePrefix(prefix))
219
+ receiveSafely(current, entry.message.removePrefix(prefix))
180
220
  }
181
221
  })
182
222
  null
@@ -194,7 +234,12 @@ class MainActivity : AppCompatActivity() {
194
234
  const port = await android.getNamedPort('craft');
195
235
  $routePrelude
196
236
  globalThis.__stxNativeBridge = {
237
+ platform: 'android',
197
238
  mutationProtocolVersion: 1,
239
+ capabilityProtocolVersion: $CRAFT_NATIVE_CAPABILITY_PROTOCOL_VERSION,
240
+ capabilityTimeoutMs: $CRAFT_NATIVE_CAPABILITY_TIMEOUT_MS,
241
+ capabilities: ${capabilities.names()},
242
+ initialAppState: ${JSONObject.quote(nativeAppState)},
198
243
  postMessage: function(value) { port.postMessage(value); },
199
244
  onMessage: function(callback) { port.onmessage = function(event) { callback(event.data); }; }
200
245
  };
@@ -202,7 +247,12 @@ class MainActivity : AppCompatActivity() {
202
247
  """.trimIndent() else """
203
248
  $routePrelude
204
249
  globalThis.__stxNativeBridge = {
250
+ platform: 'android',
205
251
  mutationProtocolVersion: 1,
252
+ capabilityProtocolVersion: $CRAFT_NATIVE_CAPABILITY_PROTOCOL_VERSION,
253
+ capabilityTimeoutMs: $CRAFT_NATIVE_CAPABILITY_TIMEOUT_MS,
254
+ capabilities: ${capabilities.names()},
255
+ initialAppState: ${JSONObject.quote(nativeAppState)},
206
256
  postMessage: function(value) { console.log(${JSONObject.quote(prefix)} + value); },
207
257
  onMessage: function(callback) { globalThis.__stxNativeCallback = callback; }
208
258
  };
@@ -214,7 +264,18 @@ class MainActivity : AppCompatActivity() {
214
264
  setup.get()
215
265
  val load = isolate.evaluateJavaScriptAsync(bundle)
216
266
  load.addListener({
217
- try { load.get() } catch (error: Exception) {
267
+ try {
268
+ load.get()
269
+ val launchLink = initialDeepLink
270
+ if (!deliveredInitialDeepLink
271
+ && config.optBoolean("enableDeepLinks")
272
+ && launchLink != null
273
+ && screens.lastOrNull() === activeScreen
274
+ ) {
275
+ deliveredInitialDeepLink = true
276
+ send(activeScreen, "DEEP_LINK", deepLinkData(launchLink).put("initial", true))
277
+ }
278
+ } catch (error: Exception) {
218
279
  Log.e(TAG, "Native STX bundle failed", error)
219
280
  if (activeScreen.active) showError("Native STX bundle failed: ${error.message}")
220
281
  }
@@ -232,9 +293,17 @@ class MainActivity : AppCompatActivity() {
232
293
  root.addView(screen.view)
233
294
  }
234
295
 
296
+ override fun onSaveInstanceState(outState: Bundle) {
297
+ outState.putString("craft.initialDeepLink", initialDeepLink?.toString())
298
+ outState.putBoolean("craft.initialDeepLinkClaimed", initialDeepLinkClaimed)
299
+ outState.putBoolean("craft.deliveredInitialDeepLink", deliveredInitialDeepLink)
300
+ super.onSaveInstanceState(outState)
301
+ }
302
+
235
303
  private fun receive(screen: Screen, raw: String) {
236
304
  val message = runCatching { JSONObject(raw) }.getOrNull() ?: return
237
305
  val payload = message.optJSONObject("payload") ?: JSONObject()
306
+ Log.d(TAG, "Native STX ${message.optString("type")} (${raw.length} chars) for ${screen.route ?: "home"}")
238
307
  when (message.optString("type")) {
239
308
  "RENDER" -> payload.optJSONObject("document")?.let { render(screen, it) }
240
309
  "MUTATE" -> try {
@@ -270,6 +339,26 @@ class MainActivity : AppCompatActivity() {
270
339
  }
271
340
  "NAVIGATE_BACK" -> if (screens.lastOrNull() === screen) back()
272
341
  "API_REQUEST" -> answerAPI(screen, message, payload)
342
+ "API_CANCEL" -> {
343
+ val id = payload.optString("requestId")
344
+ if (id.isNotBlank()) {
345
+ val token = "${screen.capabilityScope}/$id"
346
+ screen.pendingCapabilities.remove(token)
347
+ screen.capabilityTimeouts.remove(token)?.let(capabilityHandler::removeCallbacks)
348
+ capabilities.cancel(token)
349
+ }
350
+ }
351
+ }
352
+ }
353
+
354
+ private fun receiveSafely(screen: Screen, raw: String) {
355
+ try {
356
+ receive(screen, raw)
357
+ }
358
+ catch (error: Exception) {
359
+ val route = screen.route ?: "home"
360
+ Log.e(TAG, "Native STX message failed for $route", error)
361
+ if (screen.active) showError("Native STX message failed for $route: ${error.message}")
273
362
  }
274
363
  }
275
364
 
@@ -728,34 +817,118 @@ class MainActivity : AppCompatActivity() {
728
817
  val id = message.optString("id")
729
818
  if (id.isBlank()) return
730
819
  val args = payload.optJSONArray("args") ?: JSONArray()
731
- val response = JSONObject().put("requestId", id)
732
- val result = when (payload.optString("module") to payload.optString("method")) {
733
- "Device" to "getInfo" -> response.put("data", JSONObject()
820
+ val token = "${screen.capabilityScope}/$id"
821
+ screen.pendingCapabilities.add(token)
822
+ val timeout = Runnable {
823
+ if (!screen.active || !screen.pendingCapabilities.remove(token)) return@Runnable
824
+ screen.capabilityTimeouts.remove(token)
825
+ capabilities.cancel(token)
826
+ send(screen, "API_ERROR", JSONObject()
827
+ .put("version", CRAFT_NATIVE_CAPABILITY_PROTOCOL_VERSION)
828
+ .put("requestId", id)
829
+ .put("code", "TIMEOUT")
830
+ .put("message", "Native API request timed out"), id)
831
+ }
832
+ screen.capabilityTimeouts[token] = timeout
833
+ capabilityHandler.postDelayed(timeout, CRAFT_NATIVE_CAPABILITY_TIMEOUT_MS)
834
+ capabilities.perform(
835
+ token,
836
+ payload.optInt("version", CRAFT_NATIVE_CAPABILITY_PROTOCOL_VERSION),
837
+ payload.optString("module"),
838
+ payload.optString("method"),
839
+ args,
840
+ ) { result ->
841
+ if (!screen.active || !screen.pendingCapabilities.remove(token)) return@perform
842
+ screen.capabilityTimeouts.remove(token)?.let(capabilityHandler::removeCallbacks)
843
+ val response = JSONObject()
844
+ .put("version", CRAFT_NATIVE_CAPABILITY_PROTOCOL_VERSION)
845
+ .put("requestId", id)
846
+ if (result.code == null) response.put("data", result.data ?: JSONObject.NULL)
847
+ else response.put("code", result.code).put("message", result.message ?: "Native API failed")
848
+ send(screen, if (result.code == null) "API_RESPONSE" else "API_ERROR", response, id)
849
+ }
850
+ }
851
+
852
+ internal fun performImmediateCapability(module: String, method: String, args: JSONArray): CraftNativeCapabilityResult =
853
+ when (module to method) {
854
+ "Device" to "getInfo" -> CraftNativeCapabilityResult(data = JSONObject()
734
855
  .put("platform", "android")
735
856
  .put("osVersion", Build.VERSION.RELEASE)
736
857
  .put("model", Build.MODEL)
737
- .put("manufacturer", Build.MANUFACTURER))
738
- "Clipboard" to "write" -> {
858
+ .put("manufacturer", Build.MANUFACTURER)
859
+ .put("deviceId", android.provider.Settings.Secure.getString(contentResolver, android.provider.Settings.Secure.ANDROID_ID))
860
+ .put("isTablet", resources.configuration.smallestScreenWidthDp >= 600)
861
+ .put("screen", JSONObject()
862
+ .put("width", resources.displayMetrics.widthPixels / resources.displayMetrics.density)
863
+ .put("height", resources.displayMetrics.heightPixels / resources.displayMetrics.density)
864
+ .put("scale", resources.displayMetrics.density)))
865
+ "Clipboard" to "write" -> if (!clipboardEnabled) {
866
+ CraftNativeCapabilityResult(code = "CAPABILITY_DISABLED", message = "Clipboard is disabled")
867
+ } else if (args.opt(0) !is String) {
868
+ CraftNativeCapabilityResult(code = "INVALID_ARGUMENT", message = "Clipboard.write needs text")
869
+ } else {
739
870
  val clipboard = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
740
- clipboard.setPrimaryClip(ClipData.newPlainText("", args.optString(0)))
741
- response.put("data", JSONObject().put("ok", true))
871
+ clipboard.setPrimaryClip(ClipData.newPlainText("", args.getString(0)))
872
+ CraftNativeCapabilityResult(data = true)
742
873
  }
743
- "Clipboard" to "read" -> {
874
+ "Clipboard" to "read" -> if (!clipboardEnabled) {
875
+ CraftNativeCapabilityResult(code = "CAPABILITY_DISABLED", message = "Clipboard is disabled")
876
+ } else {
744
877
  val clipboard = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
745
- response.put("data", clipboard.primaryClip?.getItemAt(0)?.coerceToText(this)?.toString() ?: "")
878
+ CraftNativeCapabilityResult(data = clipboard.primaryClip?.getItemAt(0)?.coerceToText(this)?.toString() ?: "")
746
879
  }
747
- "Haptics" to "impact" -> {
748
- if (!hapticsEnabled) response.put("code", "CAPABILITY_DISABLED")
749
- .put("message", "Haptics are disabled")
750
- else {
751
- val vibrator = getSystemService(Context.VIBRATOR_SERVICE) as Vibrator
752
- vibrator.vibrate(VibrationEffect.createOneShot(30, VibrationEffect.DEFAULT_AMPLITUDE))
753
- response.put("data", JSONObject().put("ok", true))
754
- }
880
+ "Haptics" to "impact" -> if (!hapticsEnabled) {
881
+ CraftNativeCapabilityResult(code = "CAPABILITY_DISABLED", message = "Haptics are disabled")
882
+ } else {
883
+ val vibrator = getSystemService(Context.VIBRATOR_SERVICE) as Vibrator
884
+ vibrator.vibrate(VibrationEffect.createOneShot(30, VibrationEffect.DEFAULT_AMPLITUDE))
885
+ CraftNativeCapabilityResult(data = true)
755
886
  }
756
- else -> response.put("code", "UNKNOWN_ACTION").put("message", "Unsupported native API")
887
+ else -> CraftNativeCapabilityResult(code = "UNKNOWN_ACTION", message = "Unsupported native API")
757
888
  }
758
- send(screen, if (result.has("code")) "API_ERROR" else "API_RESPONSE", result, id)
889
+
890
+ internal fun deepLinkData(uri: android.net.Uri): JSONObject = JSONObject()
891
+ .put("url", uri.toString())
892
+ .put("scheme", uri.scheme ?: "")
893
+ .put("host", uri.host ?: "")
894
+ .put("path", uri.path ?: "")
895
+ .put("query", uri.query ?: "")
896
+ .put("queryParams", JSONObject().apply {
897
+ uri.queryParameterNames.forEach { name -> put(name, uri.getQueryParameter(name) ?: "") }
898
+ })
899
+
900
+ private fun publish(type: String, payload: JSONObject) {
901
+ screens.lastOrNull()?.takeIf { it.active }?.let { send(it, type, payload) }
902
+ }
903
+
904
+ override fun onStart() {
905
+ super.onStart()
906
+ nativeAppState = "active"
907
+ publish("APP_STATE", JSONObject().put("state", nativeAppState))
908
+ }
909
+
910
+ override fun onPause() {
911
+ nativeAppState = "inactive"
912
+ publish("APP_STATE", JSONObject().put("state", nativeAppState))
913
+ super.onPause()
914
+ }
915
+
916
+ override fun onStop() {
917
+ nativeAppState = "background"
918
+ publish("APP_STATE", JSONObject().put("state", nativeAppState))
919
+ super.onStop()
920
+ }
921
+
922
+ override fun onNewIntent(intent: Intent) {
923
+ super.onNewIntent(intent)
924
+ setIntent(intent)
925
+ intent.data?.let { publish("DEEP_LINK", deepLinkData(it).put("initial", false)) }
926
+ }
927
+
928
+ internal fun consumeInitialDeepLink(): Uri? {
929
+ if (initialDeepLinkClaimed) return null
930
+ initialDeepLinkClaimed = true
931
+ return initialDeepLink
759
932
  }
760
933
 
761
934
  private fun send(screen: Screen, type: String, payload: JSONObject, correlationId: String? = null) {
@@ -804,6 +977,10 @@ class MainActivity : AppCompatActivity() {
804
977
  }
805
978
 
806
979
  private fun closeScreen(screen: Screen) {
980
+ screen.capabilityTimeouts.values.forEach(capabilityHandler::removeCallbacks)
981
+ screen.capabilityTimeouts.clear()
982
+ screen.pendingCapabilities.forEach(capabilities::cancel)
983
+ screen.pendingCapabilities.clear()
807
984
  releaseViewState(screen, retainedViews(screen).toList())
808
985
  screen.controls.clear()
809
986
  screen.handlers.clear()
@@ -833,9 +1010,14 @@ class MainActivity : AppCompatActivity() {
833
1010
  imageJobs.values.forEach { it.cancel(true) }
834
1011
  imageJobs.clear()
835
1012
  imageExecutor.shutdownNow()
836
- sandbox?.close()
1013
+ capabilities.close()
837
1014
  super.onDestroy()
838
1015
  }
839
1016
 
840
- companion object { private const val TAG = "CraftNativeAndroid" }
1017
+ companion object {
1018
+ private const val TAG = "CraftNativeAndroid"
1019
+ private val SANDBOX_LOCK = Any()
1020
+ private var sharedSandbox: JavaScriptSandbox? = null
1021
+ private var sharedSandboxConnection: ListenableFuture<JavaScriptSandbox>? = null
1022
+ }
841
1023
  }