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