feat(companion): party app-sharing, intro Mesh Party button, kiosk session retention — 0.5.8 #116
@ -11,8 +11,8 @@ android {
|
|||||||
applicationId = "com.archipelago.app"
|
applicationId = "com.archipelago.app"
|
||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = 35
|
targetSdk = 35
|
||||||
versionCode = 27
|
versionCode = 28
|
||||||
versionName = "0.5.7"
|
versionName = "0.5.8"
|
||||||
|
|
||||||
vectorDrawables {
|
vectorDrawables {
|
||||||
useSupportLibrary = true
|
useSupportLibrary = true
|
||||||
|
|||||||
@ -23,6 +23,18 @@
|
|||||||
android:usesCleartextTraffic="true"
|
android:usesCleartextTraffic="true"
|
||||||
tools:targetApi="35">
|
tools:targetApi="35">
|
||||||
|
|
||||||
|
<!-- Party-screen "Share this app": exposes the copied APK from
|
||||||
|
cache/share/ to the system share sheet, nothing else. -->
|
||||||
|
<provider
|
||||||
|
android:name="androidx.core.content.FileProvider"
|
||||||
|
android:authorities="${applicationId}.fileprovider"
|
||||||
|
android:exported="false"
|
||||||
|
android:grantUriPermissions="true">
|
||||||
|
<meta-data
|
||||||
|
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||||
|
android:resource="@xml/file_paths" />
|
||||||
|
</provider>
|
||||||
|
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
|
|||||||
@ -126,6 +126,9 @@ fun AppNavHost(
|
|||||||
) {
|
) {
|
||||||
composable(Routes.INTRO) {
|
composable(Routes.INTRO) {
|
||||||
IntroScreen(
|
IntroScreen(
|
||||||
|
onMeshParty = {
|
||||||
|
navController.navigate(Routes.MESH_PARTY)
|
||||||
|
},
|
||||||
onContinue = {
|
onContinue = {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
prefs.markIntroSeen()
|
prefs.markIntroSeen()
|
||||||
|
|||||||
@ -55,7 +55,12 @@ import com.archipelago.app.ui.theme.TextPrimary
|
|||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun IntroScreen(onContinue: () -> 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) }
|
val logoAlpha = remember { Animatable(0f) }
|
||||||
var showContent by remember { mutableStateOf(false) }
|
var showContent by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
@ -143,6 +148,14 @@ fun IntroScreen(onContinue: () -> Unit) {
|
|||||||
onClick = onContinue,
|
onClick = onContinue,
|
||||||
modifier = Modifier.fillMaxWidth().height(56.dp),
|
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),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -281,6 +281,16 @@ fun PartyScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
Spacer(Modifier.height(12.dp))
|
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
|
// Party QR scanner overlay
|
||||||
@ -364,3 +374,28 @@ private fun renderQr(payload: String, size: Int = 640): Bitmap? = try {
|
|||||||
} catch (_: Exception) {
|
} catch (_: Exception) {
|
||||||
null
|
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.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -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]. */
|
/** True when a TCP listener answers at [base]'s host:port within [timeoutMs]. */
|
||||||
private fun tcpAnswers(base: String, timeoutMs: Int): Boolean = try {
|
private fun tcpAnswers(base: String, timeoutMs: Int): Boolean = try {
|
||||||
val u = android.net.Uri.parse(base)
|
val u = android.net.Uri.parse(base)
|
||||||
@ -213,6 +230,13 @@ fun WebViewScreen(
|
|||||||
var startUrl by remember(serverUrl) { mutableStateOf<String?>(null) }
|
var startUrl by remember(serverUrl) { mutableStateOf<String?>(null) }
|
||||||
var raceNonce by remember { mutableIntStateOf(0) }
|
var raceNonce by remember { mutableIntStateOf(0) }
|
||||||
LaunchedEffect(serverUrl, meshFallbackUrl, raceNonce) {
|
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)
|
val picked = pickStartUrl(serverUrl, meshFallbackUrl)
|
||||||
// Starting on the mesh: don't bounce back to it on error (it IS it).
|
// Starting on the mesh: don't bounce back to it on error (it IS it).
|
||||||
if (picked != serverUrl) triedMeshFallback = true
|
if (picked != serverUrl) triedMeshFallback = true
|
||||||
@ -328,7 +352,10 @@ fun WebViewScreen(
|
|||||||
text = stringResource(R.string.retry),
|
text = stringResource(R.string.retry),
|
||||||
onClick = {
|
onClick = {
|
||||||
// Re-race LAN vs mesh — the network we're on may have
|
// 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
|
hasError = false
|
||||||
isLoading = true
|
isLoading = true
|
||||||
triedMeshFallback = false
|
triedMeshFallback = false
|
||||||
@ -342,7 +369,10 @@ fun WebViewScreen(
|
|||||||
|
|
||||||
GlassButton(
|
GlassButton(
|
||||||
text = stringResource(R.string.disconnect),
|
text = stringResource(R.string.disconnect),
|
||||||
onClick = onDisconnect,
|
onClick = {
|
||||||
|
KioskWebView.drop()
|
||||||
|
onDisconnect()
|
||||||
|
},
|
||||||
modifier = Modifier.fillMaxWidth().height(48.dp),
|
modifier = Modifier.fillMaxWidth().height(48.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -359,7 +389,15 @@ fun WebViewScreen(
|
|||||||
AndroidView(
|
AndroidView(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
factory = { context ->
|
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(
|
layoutParams = ViewGroup.LayoutParams(
|
||||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||||
@ -665,7 +703,11 @@ fun WebViewScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
webView = this
|
webView = this
|
||||||
loadUrl(initialUrl)
|
if (reused == null) {
|
||||||
|
KioskWebView.instance = this
|
||||||
|
KioskWebView.url = serverUrl
|
||||||
|
loadUrl(initialUrl)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
@ -11,6 +11,7 @@
|
|||||||
<string name="welcome_title">Your Sovereign\nPersonal Server</string>
|
<string name="welcome_title">Your Sovereign\nPersonal Server</string>
|
||||||
<string name="welcome_subtitle">Bitcoin node, app platform, and private cloud — all in one box you control.</string>
|
<string name="welcome_subtitle">Bitcoin node, app platform, and private cloud — all in one box you control.</string>
|
||||||
<string name="get_started">Get Started</string>
|
<string name="get_started">Get Started</string>
|
||||||
|
<string name="mesh_party">Mesh Party</string>
|
||||||
<string name="use_https">Use HTTPS</string>
|
<string name="use_https">Use HTTPS</string>
|
||||||
<string name="port_label">Port (optional)</string>
|
<string name="port_label">Port (optional)</string>
|
||||||
<string name="saved_servers">Saved Servers</string>
|
<string name="saved_servers">Saved Servers</string>
|
||||||
|
|||||||
5
Android/app/src/main/res/xml/file_paths.xml
Normal file
5
Android/app/src/main/res/xml/file_paths.xml
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- FileProvider scope for the party-screen "Share this app" APK handoff. -->
|
||||||
|
<paths>
|
||||||
|
<cache-path name="share" path="share/" />
|
||||||
|
</paths>
|
||||||
Binary file not shown.
Loading…
x
Reference in New Issue
Block a user