diff --git a/Android/app/build.gradle.kts b/Android/app/build.gradle.kts index de832e18..732693b2 100644 --- a/Android/app/build.gradle.kts +++ b/Android/app/build.gradle.kts @@ -11,8 +11,8 @@ android { applicationId = "com.archipelago.app" minSdk = 26 targetSdk = 35 - versionCode = 27 - versionName = "0.5.7" + versionCode = 28 + versionName = "0.5.8" vectorDrawables { useSupportLibrary = true diff --git a/Android/app/src/main/AndroidManifest.xml b/Android/app/src/main/AndroidManifest.xml index bad2ab4d..1ed5e9a3 100644 --- a/Android/app/src/main/AndroidManifest.xml +++ b/Android/app/src/main/AndroidManifest.xml @@ -23,6 +23,18 @@ android:usesCleartextTraffic="true" tools:targetApi="35"> + + + + + Unit) { +fun IntroScreen( + onContinue: () -> Unit, + // Mesh Party works with no node at all (phone↔phone) — offered right on + // the first screen so a friend who just got the app can join a party. + onMeshParty: () -> Unit = {}, +) { val logoAlpha = remember { Animatable(0f) } var showContent by remember { mutableStateOf(false) } @@ -143,6 +148,14 @@ fun IntroScreen(onContinue: () -> Unit) { onClick = onContinue, modifier = Modifier.fillMaxWidth().height(56.dp), ) + + Spacer(modifier = Modifier.height(12.dp)) + + GlassButton( + text = stringResource(R.string.mesh_party), + onClick = onMeshParty, + modifier = Modifier.fillMaxWidth().height(48.dp), + ) } } } diff --git a/Android/app/src/main/java/com/archipelago/app/ui/screens/PartyScreen.kt b/Android/app/src/main/java/com/archipelago/app/ui/screens/PartyScreen.kt index a8042182..e16ebfb9 100644 --- a/Android/app/src/main/java/com/archipelago/app/ui/screens/PartyScreen.kt +++ b/Android/app/src/main/java/com/archipelago/app/ui/screens/PartyScreen.kt @@ -281,6 +281,16 @@ fun PartyScreen( ) } Spacer(Modifier.height(12.dp)) + // Hand the app itself to a friend: shares this install's own APK + // through the system sheet (Quick Share/Bluetooth), so a nearby + // phone gets the companion with zero internet — the whole party + // premise. + GlassButton( + text = "Share this app", + onClick = { shareCompanionApk(context) }, + modifier = Modifier.fillMaxWidth().height(48.dp), + ) + Spacer(Modifier.height(12.dp)) } // Party QR scanner overlay @@ -364,3 +374,28 @@ private fun renderQr(payload: String, size: Int = 640): Bitmap? = try { } catch (_: Exception) { null } + +/** Share this install's own APK via the system share sheet — a nearby friend + * gets the companion with no internet at all (Quick Share / Bluetooth). */ +private fun shareCompanionApk(context: android.content.Context) { + try { + val src = java.io.File(context.applicationInfo.sourceDir) + val dir = java.io.File(context.cacheDir, "share").apply { mkdirs() } + val out = java.io.File(dir, "archipelago-companion.apk") + src.copyTo(out, overwrite = true) + val uri = androidx.core.content.FileProvider.getUriForFile( + context, "${context.packageName}.fileprovider", out, + ) + val send = android.content.Intent(android.content.Intent.ACTION_SEND).apply { + type = "application/vnd.android.package-archive" + putExtra(android.content.Intent.EXTRA_STREAM, uri) + addFlags(android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + context.startActivity( + android.content.Intent.createChooser(send, "Share Archipelago Companion") + .addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK), + ) + } catch (_: Exception) { + // No share targets / copy failed — nothing sensible to do here. + } +} diff --git a/Android/app/src/main/java/com/archipelago/app/ui/screens/WebViewScreen.kt b/Android/app/src/main/java/com/archipelago/app/ui/screens/WebViewScreen.kt index fd7808ab..2cc9b247 100644 --- a/Android/app/src/main/java/com/archipelago/app/ui/screens/WebViewScreen.kt +++ b/Android/app/src/main/java/com/archipelago/app/ui/screens/WebViewScreen.kt @@ -123,6 +123,23 @@ private fun isSameHost(url: String, base: String): Boolean { } } +/** Kiosk WebView retained across navigation (remote ⇄ dashboard) so leaving + * the kiosk and coming back reattaches the LIVE page — no reload, no + * re-login, no reconnect. Dropped on retry/disconnect/server change. */ +private object KioskWebView { + var instance: WebView? = null + var url: String? = null + + fun drop() { + instance?.let { + (it.parent as? ViewGroup)?.removeView(it) + it.destroy() + } + instance = null + url = null + } +} + /** True when a TCP listener answers at [base]'s host:port within [timeoutMs]. */ private fun tcpAnswers(base: String, timeoutMs: Int): Boolean = try { val u = android.net.Uri.parse(base) @@ -213,6 +230,13 @@ fun WebViewScreen( var startUrl by remember(serverUrl) { mutableStateOf(null) } var raceNonce by remember { mutableIntStateOf(0) } LaunchedEffect(serverUrl, meshFallbackUrl, raceNonce) { + // A retained live session exists — reattach instantly: no race, no + // reload, no re-login (remote ⇄ dashboard round trip). + if (KioskWebView.instance != null && KioskWebView.url == serverUrl) { + isLoading = false + startUrl = serverUrl + return@LaunchedEffect + } val picked = pickStartUrl(serverUrl, meshFallbackUrl) // Starting on the mesh: don't bounce back to it on error (it IS it). if (picked != serverUrl) triedMeshFallback = true @@ -328,7 +352,10 @@ fun WebViewScreen( text = stringResource(R.string.retry), onClick = { // Re-race LAN vs mesh — the network we're on may have - // changed since the last pick. + // changed since the last pick. Drop the retained view: + // an errored session must genuinely reload. + KioskWebView.drop() + webView = null hasError = false isLoading = true triedMeshFallback = false @@ -342,7 +369,10 @@ fun WebViewScreen( GlassButton( text = stringResource(R.string.disconnect), - onClick = onDisconnect, + onClick = { + KioskWebView.drop() + onDisconnect() + }, modifier = Modifier.fillMaxWidth().height(48.dp), ) } @@ -359,7 +389,15 @@ fun WebViewScreen( AndroidView( modifier = Modifier.fillMaxSize(), factory = { context -> - WebView(context).apply { + // Reattach the retained kiosk WebView (remote ⇄ dashboard + // must not reload the node UI). Everything configured + // below is idempotent, and re-running it rebinds clients, + // bridges and listeners to THIS composition's state — + // stale closures from the previous visit are replaced. + if (KioskWebView.url != serverUrl) KioskWebView.drop() + val reused = KioskWebView.instance + (reused ?: WebView(context)).apply { + (parent as? ViewGroup)?.removeView(this) layoutParams = ViewGroup.LayoutParams( ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT, @@ -665,7 +703,11 @@ fun WebViewScreen( } webView = this - loadUrl(initialUrl) + if (reused == null) { + KioskWebView.instance = this + KioskWebView.url = serverUrl + loadUrl(initialUrl) + } } }, ) diff --git a/Android/app/src/main/res/values/strings.xml b/Android/app/src/main/res/values/strings.xml index 40be6925..6f92ff2c 100644 --- a/Android/app/src/main/res/values/strings.xml +++ b/Android/app/src/main/res/values/strings.xml @@ -11,6 +11,7 @@ Your Sovereign\nPersonal Server Bitcoin node, app platform, and private cloud — all in one box you control. Get Started + Mesh Party Use HTTPS Port (optional) Saved Servers diff --git a/Android/app/src/main/res/xml/file_paths.xml b/Android/app/src/main/res/xml/file_paths.xml new file mode 100644 index 00000000..40e6b2d8 --- /dev/null +++ b/Android/app/src/main/res/xml/file_paths.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/neode-ui/public/packages/archipelago-companion.apk b/neode-ui/public/packages/archipelago-companion.apk index d48fe5b3..a343162a 100644 Binary files a/neode-ui/public/packages/archipelago-companion.apk and b/neode-ui/public/packages/archipelago-companion.apk differ