ReviewOS

also looking at this

craft-native/craft

fix(android): recover remote loads after connectivity returns

#265
Merged glennmichael123 wants to merge codex/android-load-failure-fallback into main
4 files +130 -11
packages/android/compile-fixtures/CraftLoadFailureTest.ktadded+22-0
Changes to packages/android/compile-fixtures/CraftLoadFailureTest.kt
@@ -0,0 +1,22 @@
1package {{PACKAGE_NAME}}
2
3import android.webkit.WebViewClient
4import org.junit.Assert.assertFalse
5import org.junit.Assert.assertTrue
6import org.junit.Test
7
8class CraftLoadFailureTest {
9 @Test
10 fun onlyConnectivityFailuresUseTheBundledCopy() {
11 assertTrue(CraftLoadFailure.isUnreachable(WebViewClient.ERROR_HOST_LOOKUP))
12 assertTrue(CraftLoadFailure.isUnreachable(WebViewClient.ERROR_CONNECT))
13 assertTrue(CraftLoadFailure.isUnreachable(WebViewClient.ERROR_TIMEOUT))
14 assertTrue(CraftLoadFailure.isUnreachable(WebViewClient.ERROR_IO))
15
16 assertFalse(CraftLoadFailure.isUnreachable(WebViewClient.ERROR_FAILED_SSL_HANDSHAKE))
17 assertFalse(CraftLoadFailure.isUnreachable(WebViewClient.ERROR_AUTHENTICATION))
18 assertFalse(CraftLoadFailure.isUnreachable(WebViewClient.ERROR_UNSUPPORTED_AUTH_SCHEME))
19 assertFalse(CraftLoadFailure.isUnreachable(WebViewClient.ERROR_BAD_URL))
20 assertFalse(CraftLoadFailure.isUnreachable(WebViewClient.ERROR_UNSAFE_RESOURCE))
21 }
22}
packages/android/scripts/compile-templates.tsmodified+14-9
Changes to packages/android/scripts/compile-templates.ts
@@ -1,4 +1,4 @@
1import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
1import { mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs'
22import { tmpdir } from 'node:os'
33import { dirname, join } from 'node:path'
44import { fileURLToPath } from 'node:url'
@@ -13,10 +13,13 @@ interface CompileFixture {
1313
1414const scriptDirectory = dirname(fileURLToPath(import.meta.url))
1515const packageDirectory = dirname(scriptDirectory)
16const permissionTestTemplate = readFileSync(
17 join(packageDirectory, 'compile-fixtures/CraftPermissionPolicyTest.kt'),
18 'utf8',
19)
16const compileFixtureDirectory = join(packageDirectory, 'compile-fixtures')
17const compileTestTemplates = readdirSync(compileFixtureDirectory, { withFileTypes: true })
18 .filter(entry => entry.isFile() && entry.name.endsWith('Test.kt'))
19 .map(entry => ({
20 name: entry.name,
21 source: readFileSync(join(compileFixtureDirectory, entry.name), 'utf8'),
22 }))
2023const gradleExecutable = process.env.GRADLE_EXECUTABLE || 'gradle'
2124const keepProjects = process.argv.includes('--keep')
2225const workspace = mkdtempSync(join(tmpdir(), 'craft-android-templates-'))
@@ -92,10 +95,12 @@ try {
9295 fixture.packageName.replaceAll('.', '/'),
9396 )
9497 mkdirSync(testDirectory, { recursive: true })
95 writeFileSync(
96 join(testDirectory, 'CraftPermissionPolicyTest.kt'),
97 permissionTestTemplate.replaceAll('', fixture.packageName),
98 )
98 for (const testTemplate of compileTestTemplates) {
99 writeFileSync(
100 join(testDirectory, testTemplate.name),
101 testTemplate.source.replaceAll('', fixture.packageName),
102 )
103 }
99104
100105 console.log(`\nCompiling generated Android fixture: ${fixture.name}`)
101106 const result = Bun.spawnSync([
packages/android/src/index.test.tsmodified+23-1
Changes to packages/android/src/index.test.ts
@@ -306,7 +306,29 @@ describe('Craft Android builder', () => {
306306 const config = JSON.parse(readFileSync(join(output, 'app/src/main/assets/craft.config.json'), 'utf8'))
307307 const activity = readFileSync(join(output, 'app/src/main/java/org/wildloop/app/MainActivity.kt'), 'utf8')
308308 expect(config.hasBundledFallback).toBe(true)
309 expect(activity).toContain('hasBundledFallback && !loadedBundledFallback')
309
310 // #254: only a main-frame connectivity failure may hide the remote page
311 // behind the bundled copy. TLS, authentication, and bad-response failures
312 // must remain visible instead of looking like a successful offline launch.
313 const failureHandler = activity.slice(
314 activity.indexOf('override fun onReceivedError('),
315 activity.indexOf('webView.webChromeClient = WebChromeClient()'),
316 )
317 expect(failureHandler).toContain('CraftLoadFailure.isUnreachable(error.errorCode)')
318 expect(failureHandler).not.toContain('hasBundledFallback && !loadedBundledFallback')
319
320 // Falling back is no longer one-way. Bound the assertion to the named
321 // recovery method so the field declaration cannot make this pass alone.
322 const recovery = activity.slice(
323 activity.indexOf('private fun returnFromBundledFallback('),
324 activity.indexOf('private fun loadContent()'),
325 )
326 expect(recovery).toContain('loadedBundledFallback = false')
327 expect(recovery).toContain('webView.loadUrl(remoteUrl)')
328
329 expect(activity).toContain('returnFromBundledFallback("the app returned to the foreground")')
330 expect(activity).toContain('val wasConnected = isConnected')
331 expect(activity).toContain('if (!wasConnected && isConnected) {')
310332 })
311333
312334 it('delivers shortcut activations and refuses impossible event streams', async () => {
packages/android/templates/MainActivity.kt.templatemodified+71-1
Changes to packages/android/templates/MainActivity.kt.template
@@ -1,10 +1,14 @@
11package
22
33import android.annotation.SuppressLint
4import android.content.Context
45import android.os.Bundle
56import android.content.Intent
67import android.graphics.Bitmap
8import android.net.ConnectivityManager
9import android.net.Network
710import android.net.Uri
11import android.util.Log
812import android.webkit.WebResourceRequest
913import android.webkit.WebResourceError
1014import android.webkit.WebResourceResponse
@@ -19,6 +23,20 @@ import org.json.JSONObject
1923import java.io.FileNotFoundException
2024import java.net.URLConnection
2125
26/** Main-frame failures for which the bundled app is an honest fallback (#254). */
27object 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
2240class MainActivity : AppCompatActivity() {
2341 private lateinit var webView: WebView
2442 private lateinit var craftBridge: CraftBridge
@@ -32,6 +50,28 @@ class MainActivity : AppCompatActivity() {
3250 .build()
3351 }
3452 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 }
3575
3676 @SuppressLint("SetJavaScriptEnabled")
3777 override fun onCreate(savedInstanceState: Bundle?) {
@@ -68,6 +108,7 @@ class MainActivity : AppCompatActivity() {
68108
69109 setupWebView()
70110 loadContent()
111 setupNetworkMonitoring()
71112 }
72113
73114 @SuppressLint("SetJavaScriptEnabled")
@@ -113,7 +154,13 @@ class MainActivity : AppCompatActivity() {
113154
114155 override fun onReceivedError(view: WebView?, request: WebResourceRequest?, error: WebResourceError?) {
115156 super.onReceivedError(view, request, error)
116 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 ) {
117164 loadedBundledFallback = true
118165 webView.loadUrl(BUNDLED_APP_URL)
119166 }
@@ -126,6 +173,23 @@ class MainActivity : AppCompatActivity() {
126173 webView.addJavascriptInterface(craftBridge, "CraftAndroid")
127174 }
128175
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
129193 private fun loadContent() {
130194 // Check for dev server URL in config
131195 val remoteUrl = devServerUrl
@@ -234,6 +298,7 @@ class MainActivity : AppCompatActivity() {
234298 override fun onResume() {
235299 super.onResume()
236300 webView.onResume()
301 returnFromBundledFallback("the app returned to the foreground")
237302 }
238303
239304 override fun onPause() {
@@ -242,6 +307,10 @@ class MainActivity : AppCompatActivity() {
242307 }
243308
244309 override fun onDestroy() {
310 if (networkMonitoringRegistered) {
311 connectivityManager.unregisterNetworkCallback(networkCallback)
312 networkMonitoringRegistered = false
313 }
245314 craftBridge.close()
246315 webView.destroy()
247316 super.onDestroy()
@@ -263,6 +332,7 @@ class MainActivity : AppCompatActivity() {
263332 }
264333
265334 companion object {
335 private const val TAG = "CraftMainActivity"
266336 private const val BUNDLED_APP_HOST = "appassets.androidplatform.net"
267337 private const val BUNDLED_APP_ORIGIN = "https://appassets.androidplatform.net"
268338 private const val BUNDLED_APP_URL = "$BUNDLED_APP_ORIGIN/"