@craft-native/android 0.0.91 → 0.0.94

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.
@@ -1,6 +1,7 @@
1
1
  package com.craft.runtime
2
2
 
3
3
  import android.app.Activity
4
+ import android.content.IntentSender
4
5
  import com.google.android.gms.location.Priority
5
6
  import com.google.android.gms.location.LocationServices
6
7
  import com.google.android.gms.location.LocationResult
@@ -110,10 +111,24 @@ object CraftNative {
110
111
  */
111
112
  val isAvailable: Boolean = try {
112
113
  System.loadLibrary("craft")
114
+ // Said out loud, because the two outcomes below are otherwise
115
+ // indistinguishable from outside: a library that never shipped and a
116
+ // library that loaded and then failed to bind both end with the shim
117
+ // answering every call. Zig logs its own registration under the same
118
+ // tag, so the pair reads as a sequence.
119
+ android.util.Log.i("CraftNative", "craft: libcraft.so loaded")
113
120
  true
114
121
  } catch (e: UnsatisfiedLinkError) {
122
+ // The reason, not just the fact. This used to be a bare `false`, and
123
+ // the first time the runtime was genuinely shipped and genuinely
124
+ // failed to load, the only evidence anywhere was that nothing
125
+ // happened — no linker line, no exception, nothing in logcat at all.
126
+ // The message carries the dynamic linker's own complaint, which is the
127
+ // one thing that says *why*.
128
+ android.util.Log.w("CraftNative", "craft: libcraft.so did not load: ${e.message}")
115
129
  false
116
130
  } catch (e: SecurityException) {
131
+ android.util.Log.w("CraftNative", "craft: libcraft.so was refused: ${e.message}")
117
132
  false
118
133
  }
119
134
 
@@ -197,14 +212,20 @@ object CraftNative {
197
212
  /**
198
213
  * Run `script` in the page. Called from Zig, on any thread.
199
214
  *
200
- * Silent when no deliverer is installed — which is every moment before the
201
- * WebView exists, and after it is torn down. A throw here would cross back
202
- * into Zig as a pending exception on a thread that has nowhere to report
203
- * it, and the caller already counts what it could not deliver.
215
+ * Returns whether a deliverer took it. False is every moment before the
216
+ * WebView exists and after it is torn down, and it used to be
217
+ * indistinguishable from a delivery: the script was dropped, Zig's caller
218
+ * reported success, and the page waited on a promise nothing would settle
219
+ * (#231). Zig counts a false, and says so once.
220
+ *
221
+ * Still no throw. A throw here would cross back into Zig as a pending
222
+ * exception on a thread with nowhere to report it; false says the same
223
+ * thing and the caller can act on it.
204
224
  */
205
225
  @JvmStatic
206
- fun deliver(script: String) {
207
- runCatching { deliverer?.invoke(script) }
226
+ fun deliver(script: String): Boolean {
227
+ val target = deliverer ?: return false
228
+ return runCatching { target(script); true }.getOrDefault(false)
208
229
  }
209
230
 
210
231
  private external fun nativeGetDeviceInfo(activity: Activity): String?
@@ -213,7 +234,7 @@ object CraftNative {
213
234
  private external fun nativeClipboardRead(activity: Activity): String?
214
235
  private external fun nativeClipboardWrite(activity: Activity, text: String): Boolean
215
236
  private external fun nativeOpenUrl(activity: Activity, url: String): Boolean
216
- private external fun nativeShare(activity: Activity, text: String, title: String): Boolean
237
+ private external fun nativeShare(activity: Activity, text: String, title: String, chosen: IntentSender, requestCode: Int): Boolean
217
238
  private external fun nativeOpenCamera(activity: Activity): Boolean
218
239
  private external fun nativePickImage(activity: Activity): Boolean
219
240
  private external fun nativePickFile(activity: Activity): Boolean
@@ -235,7 +256,7 @@ object CraftNative {
235
256
  private external fun nativeResetDeepLinks(): Boolean
236
257
  private external fun nativeSetInitialURL(url: String?): Boolean
237
258
  private external fun nativeGetInitialURL(): Boolean
238
- private external fun nativeDispatchDeepLink(url: String): Boolean
259
+ private external fun nativeDispatchDeepLink(url: String, initial: Boolean): Boolean
239
260
  private external fun nativeScreenshotReady(bytes: ByteArray)
240
261
  private external fun nativeScreenshotError(message: String)
241
262
  private external fun nativeTakeScreenshot(activity: Activity, webView: WebView): Boolean
@@ -411,11 +432,15 @@ object CraftNative {
411
432
  }
412
433
  }
413
434
 
414
- /** Returns whether Zig launched the share sheet; false falls through. */
415
- fun share(activity: Activity, text: String, title: String): Boolean {
435
+ /**
436
+ * Returns whether Zig launched the share menu for a result; false falls
437
+ * through. `chosen` fires on a pick and `requestCode` routes the result
438
+ * back to CraftBridge, which settles the page's promise from the two.
439
+ */
440
+ fun share(activity: Activity, text: String, title: String, chosen: IntentSender, requestCode: Int): Boolean {
416
441
  if (!isAvailable) return false
417
442
  return try {
418
- nativeShare(activity, text, title)
443
+ nativeShare(activity, text, title, chosen, requestCode)
419
444
  } catch (e: UnsatisfiedLinkError) {
420
445
  false
421
446
  }
@@ -697,9 +722,9 @@ object CraftNative {
697
722
  return try { nativeGetInitialURL() } catch (e: UnsatisfiedLinkError) { false }
698
723
  }
699
724
 
700
- fun dispatchDeepLink(url: String): Boolean {
725
+ fun dispatchDeepLink(url: String, initial: Boolean): Boolean {
701
726
  if (!isAvailable) return false
702
- return try { nativeDispatchDeepLink(url) } catch (e: UnsatisfiedLinkError) { false }
727
+ return try { nativeDispatchDeepLink(url, initial) } catch (e: UnsatisfiedLinkError) { false }
703
728
  }
704
729
 
705
730
  @JvmStatic
@@ -1,10 +1,14 @@
1
1
  package {{PACKAGE_NAME}}
2
2
 
3
3
  import android.annotation.SuppressLint
4
+ import android.content.Context
4
5
  import android.os.Bundle
5
6
  import android.content.Intent
6
7
  import android.graphics.Bitmap
8
+ import android.net.ConnectivityManager
9
+ import android.net.Network
7
10
  import android.net.Uri
11
+ import android.util.Log
8
12
  import android.webkit.WebResourceRequest
9
13
  import android.webkit.WebResourceError
10
14
  import android.webkit.WebResourceResponse
@@ -19,6 +23,20 @@ import org.json.JSONObject
19
23
  import java.io.FileNotFoundException
20
24
  import java.net.URLConnection
21
25
 
26
+ /** Main-frame failures for which the bundled app is an honest fallback (#254). */
27
+ object CraftLoadFailure {
28
+ fun isUnreachable(errorCode: Int): Boolean = when (errorCode) {
29
+ WebViewClient.ERROR_HOST_LOOKUP,
30
+ WebViewClient.ERROR_CONNECT,
31
+ WebViewClient.ERROR_TIMEOUT,
32
+ // Android uses ERROR_IO for connection interruptions as well as local
33
+ // I/O failures. Keep it here deliberately: it is the closest Android
34
+ // equivalent to iOS's NSURLErrorNetworkConnectionLost.
35
+ WebViewClient.ERROR_IO -> true
36
+ else -> false
37
+ }
38
+ }
39
+
22
40
  class MainActivity : AppCompatActivity() {
23
41
  private lateinit var webView: WebView
24
42
  private lateinit var craftBridge: CraftBridge
@@ -32,6 +50,28 @@ class MainActivity : AppCompatActivity() {
32
50
  .build()
33
51
  }
34
52
  private var loadedBundledFallback = false
53
+ private lateinit var connectivityManager: ConnectivityManager
54
+ @Volatile
55
+ private var isConnected = false
56
+ private var networkMonitoringRegistered = false
57
+ private val networkCallback = object : ConnectivityManager.NetworkCallback() {
58
+ override fun onAvailable(network: Network) {
59
+ val wasConnected = isConnected
60
+ isConnected = true
61
+ if (!wasConnected && isConnected) {
62
+ runOnUiThread {
63
+ returnFromBundledFallback("the network came back")
64
+ }
65
+ }
66
+ }
67
+
68
+ override fun onLost(network: Network) {
69
+ // A second transport can already be the default by the time the
70
+ // old one reports its loss, so read current state instead of
71
+ // blindly turning the latch off.
72
+ isConnected = connectivityManager.activeNetwork != null
73
+ }
74
+ }
35
75
 
36
76
  @SuppressLint("SetJavaScriptEnabled")
37
77
  override fun onCreate(savedInstanceState: Bundle?) {
@@ -44,11 +84,31 @@ class MainActivity : AppCompatActivity() {
44
84
 
45
85
  webView = findViewById(R.id.webview)
46
86
  craftBridge = CraftBridge(this, webView, CraftHealthConnect(this, webView))
47
- handleIncomingDeepLink(intent, dispatch = false)
48
- if (savedInstanceState == null) handleIncomingShortcut(intent)
87
+ // The link that launched the app (#215). Queued until the page's bridge
88
+ // is injected, then dispatched once with `initial: true`, so a page that
89
+ // subscribes rather than calling getInitialURL still hears it.
90
+ //
91
+ // A relaunch from Recents carries the intent the app was first opened
92
+ // with. The person picked the task out of a list, not the link or the
93
+ // shortcut inside it, and neither is news the second time — so both
94
+ // are skipped, not just the link (#229).
95
+ //
96
+ // A recreated activity restores what the bridge saved rather than
97
+ // reading its intent, which onNewIntent may have replaced with a
98
+ // later warm link.
99
+ val launchedFromHistory = intent.flags and Intent.FLAG_ACTIVITY_LAUNCHED_FROM_HISTORY != 0
100
+ if (savedInstanceState == null) {
101
+ if (!launchedFromHistory) {
102
+ intent.data?.toString()?.let(craftBridge::receiveDeepLink)
103
+ handleIncomingShortcut(intent)
104
+ }
105
+ } else {
106
+ craftBridge.restoreDeepLinks(savedInstanceState)
107
+ }
49
108
 
50
109
  setupWebView()
51
110
  loadContent()
111
+ setupNetworkMonitoring()
52
112
  }
53
113
 
54
114
  @SuppressLint("SetJavaScriptEnabled")
@@ -94,7 +154,13 @@ class MainActivity : AppCompatActivity() {
94
154
 
95
155
  override fun onReceivedError(view: WebView?, request: WebResourceRequest?, error: WebResourceError?) {
96
156
  super.onReceivedError(view, request, error)
97
- if (request?.isForMainFrame == true && hasBundledFallback && !loadedBundledFallback) {
157
+ if (
158
+ request?.isForMainFrame == true &&
159
+ error != null &&
160
+ CraftLoadFailure.isUnreachable(error.errorCode) &&
161
+ hasBundledFallback &&
162
+ !loadedBundledFallback
163
+ ) {
98
164
  loadedBundledFallback = true
99
165
  webView.loadUrl(BUNDLED_APP_URL)
100
166
  }
@@ -107,6 +173,23 @@ class MainActivity : AppCompatActivity() {
107
173
  webView.addJavascriptInterface(craftBridge, "CraftAndroid")
108
174
  }
109
175
 
176
+ private fun setupNetworkMonitoring() {
177
+ if (!hasBundledFallback || devServerUrl == null) return
178
+ connectivityManager = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
179
+ isConnected = connectivityManager.activeNetwork != null
180
+ connectivityManager.registerDefaultNetworkCallback(networkCallback)
181
+ networkMonitoringRegistered = true
182
+ }
183
+
184
+ /** Leave the bundled copy for the configured remote origin again. */
185
+ private fun returnFromBundledFallback(reason: String) {
186
+ val remoteUrl = devServerUrl ?: return
187
+ if (!loadedBundledFallback) return
188
+ loadedBundledFallback = false
189
+ Log.i(TAG, "$reason; loading $remoteUrl again instead of the bundled copy")
190
+ webView.loadUrl(remoteUrl)
191
+ }
192
+
110
193
  private fun loadContent() {
111
194
  // Check for dev server URL in config
112
195
  val remoteUrl = devServerUrl
@@ -150,12 +233,6 @@ class MainActivity : AppCompatActivity() {
150
233
  }
151
234
  }
152
235
 
153
- private fun handleIncomingDeepLink(source: Intent?, dispatch: Boolean) {
154
- val url = source?.data?.toString() ?: return
155
- craftBridge.setInitialURL(url)
156
- if (dispatch) craftBridge.dispatchDeepLink(url)
157
- }
158
-
159
236
  private fun handleIncomingShortcut(source: Intent?) {
160
237
  source?.getStringExtra(SHORTCUT_TYPE_EXTRA)?.let(craftBridge::dispatchShortcut)
161
238
  }
@@ -198,10 +275,15 @@ class MainActivity : AppCompatActivity() {
198
275
  return (uri.scheme == "https" || localDevelopment) && origin in trustedOrigins
199
276
  }
200
277
 
278
+ override fun onSaveInstanceState(outState: Bundle) {
279
+ super.onSaveInstanceState(outState)
280
+ craftBridge.saveDeepLinks(outState)
281
+ }
282
+
201
283
  override fun onNewIntent(intent: Intent) {
202
284
  super.onNewIntent(intent)
203
285
  setIntent(intent)
204
- handleIncomingDeepLink(intent, dispatch = true)
286
+ intent.data?.toString()?.let(craftBridge::receiveDeepLink)
205
287
  handleIncomingShortcut(intent)
206
288
  }
207
289
 
@@ -216,6 +298,7 @@ class MainActivity : AppCompatActivity() {
216
298
  override fun onResume() {
217
299
  super.onResume()
218
300
  webView.onResume()
301
+ returnFromBundledFallback("the app returned to the foreground")
219
302
  }
220
303
 
221
304
  override fun onPause() {
@@ -224,6 +307,10 @@ class MainActivity : AppCompatActivity() {
224
307
  }
225
308
 
226
309
  override fun onDestroy() {
310
+ if (networkMonitoringRegistered) {
311
+ connectivityManager.unregisterNetworkCallback(networkCallback)
312
+ networkMonitoringRegistered = false
313
+ }
227
314
  craftBridge.close()
228
315
  webView.destroy()
229
316
  super.onDestroy()
@@ -245,6 +332,7 @@ class MainActivity : AppCompatActivity() {
245
332
  }
246
333
 
247
334
  companion object {
335
+ private const val TAG = "CraftMainActivity"
248
336
  private const val BUNDLED_APP_HOST = "appassets.androidplatform.net"
249
337
  private const val BUNDLED_APP_ORIGIN = "https://appassets.androidplatform.net"
250
338
  private const val BUNDLED_APP_URL = "$BUNDLED_APP_ORIGIN/"
package/LICENSE.md DELETED
@@ -1,21 +0,0 @@
1
- # MIT License
2
-
3
- Copyright (c) 2025 Stacks.js
4
-
5
- Permission is hereby granted, free of charge, to any person obtaining a copy
6
- of this software and associated documentation files (the "Software"), to deal
7
- in the Software without restriction, including without limitation the rights
8
- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
- copies of the Software, and to permit persons to whom the Software is
10
- furnished to do so, subject to the following conditions:
11
-
12
- The above copyright notice and this permission notice shall be included in all
13
- copies or substantial portions of the Software.
14
-
15
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
- SOFTWARE.