diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index a1cac84..0e87e85 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -11,6 +11,7 @@ on:
permissions:
contents: read
+ packages: read
jobs:
test-and-build:
@@ -76,6 +77,20 @@ jobs:
echo "$BIGFRED_STORE_FILE_CONTENT" | base64 -d > "${RUNNER_TEMP}/bigfred-release.jks"
echo "BIGFRED_STORE_FILE=${RUNNER_TEMP}/bigfred-release.jks" >> "$GITHUB_ENV"
+ - name: Set up ORAS
+ uses: oras-project/setup-oras@v1
+
+ - name: Fetch native prebuilts
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ BIGFRED_OCI_TAG: ${{ vars.BIGFRED_OCI_TAG }}
+ run: |
+ set -euo pipefail
+ TAG="${BIGFRED_OCI_TAG:-main}"
+ echo "Using loco-server OCI tag: ${TAG}"
+ make native-prebuilt BIGFRED_OCI_TAG="${TAG}"
+ ls -lh native-prebuilt/arm64-v8a/
+
- name: Unit tests
env:
GRADLE_FLAGS: ""
@@ -87,8 +102,17 @@ jobs:
BIGFRED_STORE_PASSWORD: ${{ secrets.BIGFRED_STORE_PASSWORD }}
BIGFRED_KEY_ALIAS: ${{ secrets.BIGFRED_KEY_ALIAS }}
BIGFRED_KEY_PASSWORD: ${{ secrets.BIGFRED_KEY_PASSWORD }}
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: make apk
+ - name: Verify APK contains loco-server
+ run: |
+ set -euo pipefail
+ APK="app/build/outputs/apk/release/app-release.apk"
+ unzip -l "$APK" | grep -E 'lib/arm64-v8a/libloco-server\.so'
+ unzip -l "$APK" | grep -E 'lib/arm64-v8a/libvalkey-server\.so'
+ unzip -l "$APK" | grep -E 'lib/arm64-v8a/libsupervisord\.so'
+
- name: Stage APK artifact
run: |
set -euo pipefail
diff --git a/.gitignore b/.gitignore
index 8a87a8b..c3c85f8 100644
--- a/.gitignore
+++ b/.gitignore
@@ -37,3 +37,7 @@ google-services.json
app/src/main/assets/models/
tools/hydrus-import/out/
tools/hydrus-import/__pycache__/
+
+# Native prebuilts (GHCR + deps-android-* GitHub Releases) and staged jniLibs
+native-prebuilt/
+app/src/main/jniLibs/
diff --git a/Makefile b/Makefile
index 4dc8dae..1e7b89b 100644
--- a/Makefile
+++ b/Makefile
@@ -5,6 +5,9 @@
# make debug — debug APK
# make clean — remove build outputs
# make import-models — fetch hydrus.pl catalog into assets/models/
+# make loco-android — download libloco-server.so from GHCR (ORAS)
+# make valkey-android — download libvalkey-server.so from deps-android-valkey latest release
+# make supervisord-android — download supervisord libs from deps-android-supervisord latest release
GRADLE ?= ./gradlew
GRADLE_FLAGS ?= --quiet
@@ -17,21 +20,40 @@ IMPORT_SCRIPT := tools/hydrus-import/import_models.py
IMPORT_OUT := tools/hydrus-import/out
ASSETS_MODELS := app/src/main/assets/models
-.PHONY: help apk release test test-android debug clean import-models
+NATIVE_PREBUILT := native-prebuilt/arm64-v8a
+LOCO_SO := $(NATIVE_PREBUILT)/libloco-server.so
+LOCAL_LOCO_BIN := ../bigfred/bin/loco-server-android-arm64
+VALKEY_SO := $(NATIVE_PREBUILT)/libvalkey-server.so
+SUPERVISORD_SO := $(NATIVE_PREBUILT)/libsupervisord.so
+SUPERVISORCTL_SO := $(NATIVE_PREBUILT)/libsupervisorctl.so
+
+BIGFRED_OCI_IMAGE ?= ghcr.io/dcc-bigfred/loco-server-android-arm64
+BIGFRED_OCI_TAG ?= main
+
+VALKEY_REPO ?= dcc-bigfred/deps-android-valkey
+SUPERVISORD_REPO ?= dcc-bigfred/deps-android-supervisord
+
+.PHONY: help apk release test test-android debug clean import-models \
+ loco-android valkey-android supervisord-android native-prebuilt
help:
@echo "Targets:"
- @echo " make apk Build signed release APK → $(APK_RELEASE)"
- @echo " make release Alias for apk"
- @echo " make test Run JVM unit tests"
- @echo " make test-android Run instrumented tests (device/emulator required)"
- @echo " make debug Build debug APK → $(APK_DEBUG)"
- @echo " make import-models Import hydrus models DB + thumbs → $(ASSETS_MODELS)"
- @echo " make clean Clean Gradle build outputs"
+ @echo " make apk Build signed release APK → $(APK_RELEASE)"
+ @echo " make release Alias for apk"
+ @echo " make test Run JVM unit tests"
+ @echo " make test-android Run instrumented tests (device/emulator required)"
+ @echo " make debug Build debug APK → $(APK_DEBUG)"
+ @echo " make import-models Import hydrus models DB + thumbs → $(ASSETS_MODELS)"
+ @echo " make loco-android Fetch $(LOCO_SO) (local $(LOCAL_LOCO_BIN) if present, else $(BIGFRED_OCI_IMAGE):$(BIGFRED_OCI_TAG); FORCE=1)"
+ @echo " make valkey-android Fetch $(VALKEY_SO) from $(VALKEY_REPO) latest release (skip if exists; FORCE=1)"
+ @echo " make supervisord-android Fetch supervisord libs from $(SUPERVISORD_REPO) latest release (skip if exists; FORCE=1)"
+ @echo " make clean Clean Gradle build outputs"
@echo ""
@echo "Release signing (optional; falls back to debug keystore):"
@echo " BIGFRED_STORE_FILE / BIGFRED_STORE_PASSWORD"
@echo " BIGFRED_KEY_ALIAS / BIGFRED_KEY_PASSWORD"
+ @echo ""
+ @echo "Private GitHub deps (optional): GITHUB_TOKEN / GH_TOKEN / BIGFRED_NATIVE_TOKEN"
import-models:
$(PYTHON) "$(IMPORT_SCRIPT)" --out "$(IMPORT_OUT)"
@@ -43,12 +65,52 @@ import-models:
@ls -lh "$(ASSETS_MODELS)/models.db"
@echo "Images: $$(find "$(ASSETS_MODELS)/images" -type f | wc -l)"
-apk release:
+# --- Native prebuilts (download from deps-* GitHub Releases) -----------------
+# Thin Make rules: skip when the output exists. FORCE=1 removes first.
+
+ifdef FORCE
+.PHONY: force-clean-native
+force-clean-native:
+ rm -f "$(LOCO_SO)" "$(VALKEY_SO)" "$(SUPERVISORD_SO)" "$(SUPERVISORCTL_SO)"
+loco-android: force-clean-native
+valkey-android: force-clean-native
+supervisord-android: force-clean-native
+endif
+
+loco-android: $(LOCO_SO)
+
+ifneq ($(wildcard $(LOCAL_LOCO_BIN)),)
+$(LOCO_SO): $(LOCAL_LOCO_BIN)
+ @mkdir -p "$(NATIVE_PREBUILT)"
+ @cp "$<" "$@"
+ @echo "Using local $< → $@"
+else
+$(LOCO_SO):
+ @mkdir -p "$(NATIVE_PREBUILT)"
+ ./scripts/fetch-ghcr-oras.sh "$(BIGFRED_OCI_IMAGE)" "$(BIGFRED_OCI_TAG)" "$@" main
+endif
+
+valkey-android: $(VALKEY_SO)
+
+$(VALKEY_SO):
+ ./scripts/fetch-github-release-asset.sh "$(VALKEY_REPO)" libvalkey-server.so "$@"
+
+supervisord-android: $(SUPERVISORD_SO) $(SUPERVISORCTL_SO)
+
+$(SUPERVISORD_SO):
+ ./scripts/fetch-github-release-asset.sh "$(SUPERVISORD_REPO)" libsupervisord.so "$@"
+
+$(SUPERVISORCTL_SO):
+ ./scripts/fetch-github-release-asset.sh "$(SUPERVISORD_REPO)" libsupervisorctl.so "$@"
+
+native-prebuilt: loco-android valkey-android supervisord-android
+
+apk release: native-prebuilt
$(GRADLE) $(GRADLE_FLAGS) :app:assembleRelease
@echo "APK: $(APK_RELEASE)"
@ls -lh "$(APK_RELEASE)"
-debug:
+debug: native-prebuilt
$(GRADLE) $(GRADLE_FLAGS) :app:assembleDebug
@echo "APK: $(APK_DEBUG)"
@ls -lh "$(APK_DEBUG)"
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 0bf64f4..5dbbf81 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -5,6 +5,8 @@ plugins {
id("com.google.devtools.ksp")
}
+import java.io.File
+
fun gitCommand(vararg args: String): String {
return try {
val process = ProcessBuilder("git", *args)
@@ -44,6 +46,10 @@ android {
buildConfigField("String", "GIT_COMMIT", "\"$gitCommitShort\"")
buildConfigField("String", "GIT_COMMIT_FULL", "\"$gitCommitFull\"")
buildConfigField("boolean", "GIT_DIRTY", "$gitDirty")
+
+ ndk {
+ abiFilters += "arm64-v8a"
+ }
}
signingConfigs {
@@ -90,6 +96,9 @@ android {
resources {
excludes += "/META-INF/{AL2.0,LGPL2.1}"
}
+ jniLibs {
+ useLegacyPackaging = true
+ }
}
androidResources {
@@ -97,6 +106,126 @@ android {
}
}
+// Stage native executables into jniLibs as lib*.so (executable from nativeLibraryDir).
+val fetchNativeBinaries by tasks.registering {
+ val jniOut = layout.projectDirectory.dir("src/main/jniLibs/arm64-v8a")
+ val prebuilt = rootProject.layout.projectDirectory.dir("native-prebuilt/arm64-v8a")
+ val localLoco = rootProject.layout.projectDirectory.dir("../bigfred/bin")
+ .file("loco-server-android-arm64")
+ val fetchScript = rootProject.layout.projectDirectory.file("scripts/fetch-github-release-asset.sh")
+ val ghcrScript = rootProject.layout.projectDirectory.file("scripts/fetch-ghcr-oras.sh")
+ outputs.dir(jniOut)
+ doLast {
+ val outDir = jniOut.asFile
+ outDir.mkdirs()
+
+ fun githubToken(): String? =
+ System.getenv("BIGFRED_NATIVE_TOKEN")
+ ?: System.getenv("GH_TOKEN")
+ ?: System.getenv("GITHUB_TOKEN")
+
+ fun copyIfExists(src: File, destName: String): Boolean {
+ if (!src.isFile) return false
+ src.copyTo(File(outDir, destName), overwrite = true)
+ println("Staged $destName from ${src.absolutePath}")
+ return true
+ }
+
+ fun runScript(script: File, vararg args: String) {
+ require(script.isFile) { "Missing script: ${script.absolutePath}" }
+ val pb = ProcessBuilder(listOf(script.absolutePath, *args))
+ pb.directory(rootProject.projectDir)
+ pb.redirectErrorStream(true)
+ val env = pb.environment()
+ githubToken()?.let { env["GITHUB_TOKEN"] = it }
+ val proc = pb.start()
+ val output = proc.inputStream.bufferedReader().readText()
+ val code = proc.waitFor()
+ print(output)
+ if (code != 0) {
+ throw GradleException("Script ${script.name} failed (exit $code)")
+ }
+ }
+
+ fun fetchLatestReleaseAsset(repo: String, assetName: String, dest: File) {
+ runScript(fetchScript.asFile, repo, assetName, dest.absolutePath)
+ }
+
+ fun fetchLocoFromGhcr(dest: File) {
+ val image = (project.findProperty("bigfredOciImage") as String?)
+ ?.ifBlank { null }
+ ?: System.getenv("BIGFRED_OCI_IMAGE")
+ ?: "ghcr.io/dcc-bigfred/loco-server-android-arm64"
+ val tag = (project.findProperty("bigfredOciTag") as String?)
+ ?.ifBlank { null }
+ ?: System.getenv("BIGFRED_OCI_TAG")
+ ?: "main"
+ runScript(ghcrScript.asFile, image, tag, dest.absolutePath, "main")
+ }
+
+ fun stageOrFetch(prebuiltName: String, repoProp: String, defaultRepo: String) {
+ val dest = File(outDir, prebuiltName)
+ if (copyIfExists(prebuilt.file(prebuiltName).asFile, prebuiltName)) return
+ val repo = (project.findProperty(repoProp) as String?)
+ ?.ifBlank { null }
+ ?: defaultRepo
+ fetchLatestReleaseAsset(repo, prebuiltName, dest)
+ // Keep native-prebuilt in sync for make skip-if-exists.
+ val cache = prebuilt.file(prebuiltName).asFile
+ cache.parentFile.mkdirs()
+ dest.copyTo(cache, overwrite = true)
+ }
+
+ // Valkey / supervisord: prefer make-fetched native-prebuilt, else latest GitHub release.
+ stageOrFetch(
+ "libvalkey-server.so",
+ "depsAndroidValkeyRepo",
+ "dcc-bigfred/deps-android-valkey",
+ )
+ stageOrFetch(
+ "libsupervisord.so",
+ "depsAndroidSupervisordRepo",
+ "dcc-bigfred/deps-android-supervisord",
+ )
+ stageOrFetch(
+ "libsupervisorctl.so",
+ "depsAndroidSupervisordRepo",
+ "dcc-bigfred/deps-android-supervisord",
+ )
+
+ // loco-server: local ../bigfred/bin → native-prebuilt → GHCR (ORAS).
+ val locoDest = File(outDir, "libloco-server.so")
+ val locoCached = prebuilt.file("libloco-server.so").asFile
+ when {
+ localLoco.asFile.isFile -> {
+ localLoco.asFile.copyTo(locoDest, overwrite = true)
+ println("Staged libloco-server.so from local ${localLoco.asFile}")
+ }
+ locoCached.isFile -> {
+ locoCached.copyTo(locoDest, overwrite = true)
+ println("Staged libloco-server.so from ${locoCached.absolutePath}")
+ }
+ else -> {
+ fetchLocoFromGhcr(locoDest)
+ locoDest.parentFile.mkdirs()
+ locoCached.parentFile.mkdirs()
+ locoDest.copyTo(locoCached, overwrite = true)
+ }
+ }
+ if (!locoDest.isFile || locoDest.length() < 1024) {
+ throw GradleException(
+ "libloco-server.so missing after fetch. " +
+ "Build with 'make -C ../bigfred android', place it in native-prebuilt/arm64-v8a/, " +
+ "or pull from ghcr.io/dcc-bigfred/loco-server-android-arm64 (ORAS).",
+ )
+ }
+ }
+}
+
+tasks.named("preBuild").configure {
+ dependsOn(fetchNativeBinaries)
+}
+
dependencies {
val composeBom = platform("androidx.compose:compose-bom:2024.10.01")
implementation(composeBom)
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 306cfcc..f26d2b2 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -6,6 +6,9 @@
+
+
+
@@ -33,6 +38,11 @@
+
+
Boolean)? = null
+ private val openLocalWebViewChannel = Channel(Channel.BUFFERED)
+ val openLocalWebViewRequests = openLocalWebViewChannel.receiveAsFlow()
+
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
WindowCompat.setDecorFitsSystemWindows(window, false)
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
hideSystemBars()
+ handleNotificationIntent(intent)
setContent {
BigFredTheme {
- BigFredApp()
+ BigFredApp(openLocalWebViewRequests = openLocalWebViewRequests)
}
}
}
+ /**
+ * Manual portrait ↔ landscape toggle for the SPA rotate button.
+ * Auto-rotate is disabled via android:screenOrientation="locked"; this
+ * locks to an explicit orientation until the next tap.
+ */
+ fun toggleScreenOrientation() {
+ val landscape =
+ resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE
+ requestedOrientation = if (landscape) {
+ ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
+ } else {
+ ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE
+ }
+ }
+
+ override fun onNewIntent(intent: Intent) {
+ super.onNewIntent(intent)
+ setIntent(intent)
+ handleNotificationIntent(intent)
+ }
+
+ private fun handleNotificationIntent(intent: Intent?) {
+ if (intent?.getBooleanExtra(LocoServerService.EXTRA_OPEN_LOCAL_WEBVIEW, false) != true) {
+ return
+ }
+ intent.removeExtra(LocoServerService.EXTRA_OPEN_LOCAL_WEBVIEW)
+ openLocalWebViewChannel.trySend(Unit)
+ }
+
override fun dispatchKeyEvent(event: KeyEvent): Boolean {
val code = event.keyCode
if (code == KeyEvent.KEYCODE_VOLUME_UP || code == KeyEvent.KEYCODE_VOLUME_DOWN) {
diff --git a/app/src/main/java/com/dccbigfred/android/data/ServerPreferences.kt b/app/src/main/java/com/dccbigfred/android/data/ServerPreferences.kt
index dfecd38..c4336c2 100644
--- a/app/src/main/java/com/dccbigfred/android/data/ServerPreferences.kt
+++ b/app/src/main/java/com/dccbigfred/android/data/ServerPreferences.kt
@@ -22,6 +22,7 @@ class ServerPreferences(private val context: Context) {
private val volumeKeysThrottleEnabledKey =
booleanPreferencesKey("volume_keys_throttle_enabled")
private val themeModeKey = stringPreferencesKey("theme_mode")
+ private val localJwtSecretKey = stringPreferencesKey("local_jwt_secret")
private val themeSyncPrefs =
context.getSharedPreferences(THEME_SYNC_PREFS, Context.MODE_PRIVATE)
@@ -77,6 +78,21 @@ class ServerPreferences(private val context: Context) {
}
}
+ /**
+ * Stable JWT secret for the on-phone loco-server so sessions survive
+ * process restarts within the same install.
+ */
+ suspend fun getOrCreateLocalJwtSecret(): String {
+ val existing = context.dataStore.data.map { it[localJwtSecretKey] }.first()
+ if (!existing.isNullOrBlank()) return existing
+ val generated = java.util.UUID.randomUUID().toString().replace("-", "") +
+ java.util.UUID.randomUUID().toString().replace("-", "")
+ context.dataStore.edit { prefs ->
+ prefs[localJwtSecretKey] = generated
+ }
+ return generated
+ }
+
suspend fun setThemeMode(mode: ThemeMode) {
themeSyncPrefs.edit().putString(THEME_SYNC_KEY, mode.storageValue).apply()
context.dataStore.edit { prefs ->
diff --git a/app/src/main/java/com/dccbigfred/android/server/LanPrefix.kt b/app/src/main/java/com/dccbigfred/android/server/LanPrefix.kt
new file mode 100644
index 0000000..05a4bb4
--- /dev/null
+++ b/app/src/main/java/com/dccbigfred/android/server/LanPrefix.kt
@@ -0,0 +1,48 @@
+package com.dccbigfred.android.server
+
+import android.util.Log
+import java.net.Inet4Address
+import java.net.NetworkInterface
+
+/**
+ * Resolves a LAN scan prefix (e.g. "192.168.0") via Java NetworkInterface.
+ * Used because Go's net.InterfaceAddrs uses netlink, which Android denies to apps.
+ */
+object LanPrefix {
+ private const val TAG = "LanPrefix"
+
+ /**
+ * Returns a.b.c for the first non-loopback, non-link-local IPv4, preferring /24.
+ */
+ fun resolve(): String? {
+ return try {
+ val ifaces = NetworkInterface.getNetworkInterfaces() ?: return null
+ var fallback: String? = null
+ while (ifaces.hasMoreElements()) {
+ val nif = ifaces.nextElement()
+ if (!nif.isUp || nif.isLoopback) continue
+ val addrs = nif.inetAddresses
+ while (addrs.hasMoreElements()) {
+ val addr = addrs.nextElement()
+ if (addr !is Inet4Address || addr.isLoopbackAddress || addr.isLinkLocalAddress) {
+ continue
+ }
+ val bytes = addr.address ?: continue
+ if (bytes.size != 4) continue
+ val prefix = "${bytes[0].toUByte()}.${bytes[1].toUByte()}.${bytes[2].toUByte()}"
+ // NetworkInterface does not expose prefix length portably on all APIs;
+ // prefer site-local (RFC1918) as the usual LAN case.
+ if (addr.isSiteLocalAddress) {
+ Log.i(TAG, "LAN prefix $prefix from ${nif.name} (${addr.hostAddress})")
+ return prefix
+ }
+ if (fallback == null) fallback = prefix
+ }
+ }
+ fallback?.also { Log.i(TAG, "LAN prefix $it (non-site-local fallback)") }
+ } catch (e: Exception) {
+ Log.w(TAG, "failed to resolve LAN prefix", e)
+ null
+ }
+ }
+}
diff --git a/app/src/main/java/com/dccbigfred/android/server/LocalServerPaths.kt b/app/src/main/java/com/dccbigfred/android/server/LocalServerPaths.kt
new file mode 100644
index 0000000..561479c
--- /dev/null
+++ b/app/src/main/java/com/dccbigfred/android/server/LocalServerPaths.kt
@@ -0,0 +1,28 @@
+package com.dccbigfred.android.server
+
+import android.content.Context
+import java.io.File
+
+/** Writable BigFred data tree under the app files directory. */
+data class LocalServerPaths(
+ val dataDir: File,
+) {
+ val etcDir: File get() = File(dataDir, "etc")
+ val runDir: File get() = File(dataDir, "run")
+ val logsDir: File get() = File(dataDir, "logs")
+ val redisDir: File get() = File(dataDir, "redis")
+
+ val locoServerPid: File get() = File(runDir, "loco-server.pid")
+ val valkeyPid: File get() = File(runDir, "valkey.pid")
+ val supervisordPid: File get() = File(runDir, "supervisord.pid")
+ val dbFile: File get() = File(dataDir, "bigfred.db")
+
+ fun ensureDirs() {
+ listOf(dataDir, etcDir, runDir, logsDir, redisDir).forEach { it.mkdirs() }
+ }
+
+ companion object {
+ fun from(context: Context): LocalServerPaths =
+ LocalServerPaths(File(context.filesDir, "bigfred-data"))
+ }
+}
diff --git a/app/src/main/java/com/dccbigfred/android/server/LocoServerService.kt b/app/src/main/java/com/dccbigfred/android/server/LocoServerService.kt
new file mode 100644
index 0000000..2ad03f5
--- /dev/null
+++ b/app/src/main/java/com/dccbigfred/android/server/LocoServerService.kt
@@ -0,0 +1,489 @@
+package com.dccbigfred.android.server
+
+import android.app.Notification
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.app.PendingIntent
+import android.app.Service
+import android.content.Context
+import android.content.Intent
+import android.content.pm.ServiceInfo
+import android.os.Build
+import android.os.IBinder
+import android.os.PowerManager
+import android.util.Log
+import androidx.core.app.NotificationCompat
+import androidx.core.app.ServiceCompat
+import com.dccbigfred.android.BigFredApplication
+import com.dccbigfred.android.MainActivity
+import com.dccbigfred.android.R
+import com.dccbigfred.android.network.ServerProbe
+import java.io.File
+import java.util.concurrent.atomic.AtomicBoolean
+import kotlin.concurrent.thread
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.runBlocking
+
+/**
+ * Foreground service hosting Valkey + loco-server for "BigFred on phone".
+ * Stage 1 uses `--no-supervisor`; stage 2 passes absolute jniLibs paths for supervisord.
+ */
+class LocoServerService : Service() {
+
+ private var wakeLock: PowerManager.WakeLock? = null
+ private var valkeyProcess: Process? = null
+ private var locoProcess: Process? = null
+ private var watchdogThread: Thread? = null
+ private var bootThread: Thread? = null
+ /** True while a boot is in progress; gates concurrent ACTION_START deliveries. */
+ private val booting = AtomicBoolean(false)
+ /** True once boot() reached Running; cleared on stop or boot failure. */
+ private val running = AtomicBoolean(false)
+
+ override fun onBind(intent: Intent?): IBinder? = null
+
+ override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
+ when (intent?.action) {
+ ACTION_STOP -> {
+ stopLocalServer(startId)
+ return START_NOT_STICKY
+ }
+ ACTION_RESTART -> restartLocalServer()
+ ACTION_START, null -> {
+ if (!running.get()) {
+ startLocalServer()
+ }
+ }
+ }
+ return START_STICKY
+ }
+
+ private fun startLocalServer() {
+ if (!booting.compareAndSet(false, true)) return
+ _state.value = LocalServerState.Starting
+ try {
+ startForegroundNotification()
+ } catch (e: Exception) {
+ Log.e(TAG, "startForeground failed", e)
+ booting.set(false)
+ _state.value = LocalServerState.Failed(e.message ?: e.toString())
+ stopSelf()
+ return
+ }
+ acquireWakeLock()
+ launchBoot(restart = false)
+ }
+
+ private fun restartLocalServer() {
+ if (!booting.compareAndSet(false, true)) return
+ _state.value = LocalServerState.Starting
+ try {
+ startForegroundNotification()
+ } catch (e: Exception) {
+ Log.e(TAG, "startForeground failed during restart", e)
+ booting.set(false)
+ _state.value = LocalServerState.Failed(e.message ?: e.toString())
+ stopSelf()
+ return
+ }
+ acquireWakeLock()
+ launchBoot(restart = true)
+ }
+
+ private fun launchBoot(restart: Boolean) {
+ bootThread = thread(
+ name = if (restart) "loco-server-restart" else "loco-server-boot",
+ isDaemon = true,
+ ) {
+ try {
+ if (restart) {
+ running.set(false)
+ watchdogThread?.interrupt()
+ watchdogThread = null
+ cleanupChildren()
+ ensureBootActive()
+ }
+ boot()
+ } catch (e: InterruptedException) {
+ Log.i(TAG, "boot interrupted, stopping")
+ cleanupChildren()
+ } catch (e: Exception) {
+ Log.e(TAG, "local server start failed", e)
+ cleanupChildren()
+ releaseWakeLock()
+ _state.value = LocalServerState.Failed(e.message ?: e.toString())
+ stopSelf()
+ } finally {
+ if (bootThread === Thread.currentThread()) {
+ bootThread = null
+ }
+ booting.set(false)
+ }
+ }
+ }
+
+ override fun onDestroy() {
+ cleanupChildren()
+ releaseWakeLock()
+ // Keep Failed visible so the UI can show the error after the service exits.
+ if (_state.value !is LocalServerState.Failed) {
+ _state.value = LocalServerState.Stopped
+ }
+ running.set(false)
+ booting.set(false)
+ bootThread?.interrupt()
+ bootThread = null
+ watchdogThread?.interrupt()
+ watchdogThread = null
+ super.onDestroy()
+ }
+
+ private fun boot() {
+ _state.value = LocalServerState.Starting
+ ensureBootActive()
+ val paths = LocalServerPaths.from(this)
+ paths.ensureDirs()
+
+ ProcessOrphanReaper.reap(paths.locoServerPid, NativeBinaries.LOCO_SERVER)
+ ProcessOrphanReaper.reap(paths.valkeyPid, NativeBinaries.VALKEY)
+ ProcessOrphanReaper.reap(paths.supervisordPid, NativeBinaries.SUPERVISORD)
+ ensureBootActive()
+
+ if (ProcessOrphanReaper.isPortOpen("127.0.0.1", HTTP_PORT) &&
+ ProcessOrphanReaper.readPid(paths.locoServerPid) == null
+ ) {
+ throw IllegalStateException(
+ "Port $HTTP_PORT is already in use without a known loco-server pidfile",
+ )
+ }
+
+ val locoBin = NativeBinaries.require(this, NativeBinaries.LOCO_SERVER)
+ val valkeyBin = NativeBinaries.require(this, NativeBinaries.VALKEY)
+ val supervisordBin = NativeBinaries.file(this, NativeBinaries.SUPERVISORD)
+ val supervisorctlBin = NativeBinaries.file(this, NativeBinaries.SUPERVISORCTL)
+ val supervised = supervisordBin.isFile && supervisorctlBin.isFile
+
+ val prefs = (application as BigFredApplication).serverPreferences
+ val jwt = runBlocking { prefs.getOrCreateLocalJwtSecret() }
+
+ val env = HashMap(System.getenv())
+ env["BIGFRED_DATA_DIR"] = paths.dataDir.absolutePath
+ env["BIGFRED_JWT_SECRET"] = jwt
+ LanPrefix.resolve()?.let { prefix ->
+ env["BIGFRED_LAN_PREFIX"] = prefix
+ Log.i(TAG, "BIGFRED_LAN_PREFIX=$prefix (for dcc-bus scan --lan-prefix)")
+ }
+ if (supervised) {
+ // Shim resolves sibling libsupervisord.so; also set env for clarity.
+ env["SUPERVISORD_BIN"] = supervisordBin.absolutePath
+ }
+
+ if (!supervised) {
+ // Stage 1: FGS owns Valkey; loco-server runs without supervisord.
+ bootUnmanagedValkey(paths, valkeyBin)
+ }
+
+ ensureBootActive()
+ val locoLog = File(paths.logsDir, "loco-server.log")
+ val locoArgs = mutableListOf(
+ locoBin.absolutePath,
+ "--http", "0.0.0.0:$HTTP_PORT",
+ "--db", paths.dbFile.absolutePath,
+ "--redis-bin", valkeyBin.absolutePath,
+ "--redis-addr", "127.0.0.1:$REDIS_PORT",
+ "--mdns=false",
+ )
+ if (supervised) {
+ // Stage 2: loco-server owns supervisord → Valkey + dcc-bus.
+ // Execute from nativeLibraryDir (executable); never copy to code_cache (noexec).
+ locoArgs += listOf(
+ "--supervisord-bin", supervisordBin.absolutePath,
+ "--supervisorctl-bin", supervisorctlBin.absolutePath,
+ )
+ Log.i(TAG, "starting supervised local mode (jniLibs supervisord)")
+ } else {
+ locoArgs += listOf("--redis-external", "--no-supervisor")
+ Log.i(TAG, "starting unmanaged local mode (--no-supervisor)")
+ }
+
+ locoProcess = ProcessBuilder(locoArgs)
+ .directory(paths.dataDir)
+ .redirectErrorStream(true)
+ .redirectOutput(ProcessBuilder.Redirect.appendTo(locoLog))
+ .also { it.environment().clear(); it.environment().putAll(env) }
+ .start()
+ ensureBootActive()
+ ProcessOrphanReaper.writePid(paths.locoServerPid, processPid(locoProcess!!))
+
+ waitForHttpReady(45_000)
+ // stop may have been requested while we were waiting for HTTP — bail
+ // before flipping to Running / starting the watchdog, otherwise an
+ // interrupted boot would resurrect the service after stop.
+ if (Thread.currentThread().isInterrupted || !booting.get()) {
+ throw InterruptedException("stopped during boot")
+ }
+ running.set(true)
+ _state.value = LocalServerState.Running(LOCAL_BASE_URL)
+ startWatchdog(supervised)
+ }
+
+ private fun bootUnmanagedValkey(paths: LocalServerPaths, valkeyBin: File) {
+ ensureBootActive()
+ val valkeyLog = File(paths.logsDir, "valkey.log")
+ valkeyProcess = ProcessBuilder(
+ valkeyBin.absolutePath,
+ "--bind", "127.0.0.1",
+ "--port", REDIS_PORT.toString(),
+ "--dir", paths.redisDir.absolutePath,
+ "--save", "",
+ "--appendonly", "no",
+ "--protected-mode", "no",
+ "--daemonize", "no",
+ ).redirectErrorStream(true)
+ .redirectOutput(ProcessBuilder.Redirect.appendTo(valkeyLog))
+ .start()
+ ensureBootActive()
+ ProcessOrphanReaper.writePid(paths.valkeyPid, processPid(valkeyProcess!!))
+ waitForPort("127.0.0.1", REDIS_PORT, 15_000)
+ }
+
+ private fun ensureBootActive() {
+ if (Thread.currentThread().isInterrupted || !booting.get()) {
+ throw InterruptedException("local server boot cancelled")
+ }
+ }
+
+ private fun waitForPort(host: String, port: Int, timeoutMs: Long) {
+ val deadline = System.currentTimeMillis() + timeoutMs
+ while (System.currentTimeMillis() < deadline) {
+ if (ProcessOrphanReaper.isPortOpen(host, port)) return
+ Thread.sleep(200)
+ }
+ throw IllegalStateException("Timeout waiting for $host:$port")
+ }
+
+ private fun waitForHttpReady(timeoutMs: Long) {
+ val probe = ServerProbe()
+ val deadline = System.currentTimeMillis() + timeoutMs
+ while (System.currentTimeMillis() < deadline) {
+ val ok = runBlocking { probe.isReachable(LOCAL_BASE_URL) }
+ if (ok) return
+ Thread.sleep(300)
+ }
+ throw IllegalStateException("Timeout waiting for $LOCAL_BASE_URL")
+ }
+
+ private fun startWatchdog(supervised: Boolean) {
+ watchdogThread?.interrupt()
+ watchdogThread = thread(name = "loco-server-watchdog", isDaemon = true) {
+ while (running.get() && !Thread.currentThread().isInterrupted) {
+ try {
+ Thread.sleep(3_000)
+ val locoAlive = locoProcess?.isAlive == true
+ val valkeyOk = supervised || valkeyProcess?.isAlive == true
+ if (!locoAlive || !valkeyOk) {
+ Log.w(TAG, "child died loco=$locoAlive valkeyOk=$valkeyOk — restarting")
+ cleanupChildren()
+ running.set(false)
+ if (!booting.compareAndSet(false, true)) {
+ Log.w(TAG, "restart skipped — boot already in progress")
+ return@thread
+ }
+ try {
+ boot()
+ } finally {
+ booting.set(false)
+ }
+ return@thread
+ }
+ } catch (_: InterruptedException) {
+ return@thread
+ } catch (e: Exception) {
+ Log.e(TAG, "watchdog restart failed", e)
+ _state.value = LocalServerState.Failed(e.message ?: e.toString())
+ running.set(false)
+ booting.set(false)
+ stopSelf()
+ return@thread
+ }
+ }
+ }
+ }
+
+ private fun stopLocalServer(startId: Int) {
+ running.set(false)
+ booting.set(false)
+ bootThread?.interrupt()
+ watchdogThread?.interrupt()
+ watchdogThread = null
+ _state.value = LocalServerState.Stopped
+ thread(name = "loco-server-stop", isDaemon = true) {
+ cleanupChildren()
+ releaseWakeLock()
+ stopSelf(startId)
+ }
+ }
+
+ @Synchronized
+ private fun cleanupChildren() {
+ listOf(locoProcess, valkeyProcess).forEach { proc ->
+ proc ?: return@forEach
+ try {
+ proc.destroy()
+ if (!proc.waitFor(3, java.util.concurrent.TimeUnit.SECONDS)) {
+ proc.destroyForcibly()
+ }
+ } catch (_: Exception) {
+ }
+ }
+ locoProcess = null
+ valkeyProcess = null
+ try {
+ val paths = LocalServerPaths.from(this)
+ paths.locoServerPid.delete()
+ paths.valkeyPid.delete()
+ paths.supervisordPid.delete()
+ } catch (_: Exception) {
+ }
+ }
+
+ /** java.lang.Process.pid() is only in the public Android SDK from API 31+. */
+ private fun processPid(process: Process): Long {
+ return try {
+ val method = Process::class.java.getMethod("pid")
+ (method.invoke(process) as Long)
+ } catch (_: Exception) {
+ Regex("""pid[= ](\d+)""")
+ .find(process.toString())
+ ?.groupValues
+ ?.getOrNull(1)
+ ?.toLongOrNull()
+ ?: -1L
+ }
+ }
+
+ private fun startForegroundNotification() {
+ ensureChannel()
+ val openActivityIntent = Intent(this, MainActivity::class.java).apply {
+ flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
+ putExtra(EXTRA_OPEN_LOCAL_WEBVIEW, true)
+ }
+ val openIntent = PendingIntent.getActivity(
+ this,
+ 0,
+ openActivityIntent,
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
+ )
+ val stopIntent = PendingIntent.getService(
+ this,
+ 1,
+ Intent(this, LocoServerService::class.java).setAction(ACTION_STOP),
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
+ )
+ val notification: Notification = NotificationCompat.Builder(this, CHANNEL_ID)
+ .setContentTitle(getString(R.string.local_server_notification_title))
+ .setContentText(getString(R.string.local_server_notification_text))
+ .setSmallIcon(R.mipmap.ic_launcher)
+ .setContentIntent(openIntent)
+ .addAction(0, getString(R.string.local_server_stop), stopIntent)
+ .setOngoing(true)
+ .setOnlyAlertOnce(true)
+ .build()
+
+ if (Build.VERSION.SDK_INT >= 34) {
+ ServiceCompat.startForeground(
+ this,
+ NOTIFICATION_ID,
+ notification,
+ ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE,
+ )
+ } else {
+ startForeground(NOTIFICATION_ID, notification)
+ }
+ }
+
+ private fun ensureChannel() {
+ val mgr = getSystemService(NotificationManager::class.java) ?: return
+ val channel = NotificationChannel(
+ CHANNEL_ID,
+ getString(R.string.local_server_notification_channel),
+ NotificationManager.IMPORTANCE_LOW,
+ )
+ mgr.createNotificationChannel(channel)
+ }
+
+ private fun acquireWakeLock() {
+ if (wakeLock?.isHeld == true) return
+ val pm = getSystemService(PowerManager::class.java) ?: return
+ wakeLock = pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "bigfred:local-server").also {
+ it.setReferenceCounted(false)
+ it.acquire()
+ }
+ }
+
+ private fun releaseWakeLock() {
+ try {
+ wakeLock?.takeIf { it.isHeld }?.release()
+ } catch (_: Exception) {
+ }
+ wakeLock = null
+ }
+
+ companion object {
+ private const val TAG = "LocoServerService"
+ private const val CHANNEL_ID = "bigfred_local_server"
+ private const val NOTIFICATION_ID = 42
+ const val HTTP_PORT = 8080
+ const val REDIS_PORT = 6379
+ const val LOCAL_BASE_URL = "http://127.0.0.1:$HTTP_PORT"
+ const val EXTRA_OPEN_LOCAL_WEBVIEW = "com.dccbigfred.android.OPEN_LOCAL_WEBVIEW"
+ const val ACTION_START = "com.dccbigfred.android.server.START"
+ const val ACTION_STOP = "com.dccbigfred.android.server.STOP"
+ const val ACTION_RESTART = "com.dccbigfred.android.server.RESTART"
+
+ private val _state = MutableStateFlow(LocalServerState.Stopped)
+ val state: StateFlow = _state.asStateFlow()
+
+ fun start(context: Context) {
+ if (_state.value !is LocalServerState.Running &&
+ _state.value !is LocalServerState.Starting
+ ) {
+ _state.value = LocalServerState.Starting
+ }
+ val intent = Intent(context, LocoServerService::class.java).setAction(ACTION_START)
+ try {
+ context.startForegroundService(intent)
+ } catch (e: Exception) {
+ Log.e(TAG, "startForegroundService failed", e)
+ _state.value = LocalServerState.Failed(e.message ?: e.toString())
+ }
+ }
+
+ fun stop(context: Context) {
+ val intent = Intent(context, LocoServerService::class.java).setAction(ACTION_STOP)
+ context.startService(intent)
+ }
+
+ fun restart(context: Context) {
+ val intent = Intent(context, LocoServerService::class.java).setAction(ACTION_RESTART)
+ try {
+ context.startForegroundService(intent)
+ } catch (e: Exception) {
+ Log.e(TAG, "restart foreground service failed", e)
+ _state.value = LocalServerState.Failed(e.message ?: e.toString())
+ }
+ }
+
+ fun isLocalUrl(url: String?): Boolean =
+ url != null && (url.contains("127.0.0.1") || url.contains("localhost"))
+ }
+}
+
+sealed class LocalServerState {
+ data object Stopped : LocalServerState()
+ data object Starting : LocalServerState()
+ data class Running(val baseUrl: String) : LocalServerState()
+ data class Failed(val message: String) : LocalServerState()
+}
diff --git a/app/src/main/java/com/dccbigfred/android/server/NativeBinaries.kt b/app/src/main/java/com/dccbigfred/android/server/NativeBinaries.kt
new file mode 100644
index 0000000..f04c25f
--- /dev/null
+++ b/app/src/main/java/com/dccbigfred/android/server/NativeBinaries.kt
@@ -0,0 +1,27 @@
+package com.dccbigfred.android.server
+
+import android.content.Context
+import android.content.pm.ApplicationInfo
+import java.io.File
+
+/** Resolves extracted jniLibs executables under [ApplicationInfo.nativeLibraryDir]. */
+object NativeBinaries {
+ const val LOCO_SERVER = "libloco-server.so"
+ const val VALKEY = "libvalkey-server.so"
+ const val SUPERVISORD = "libsupervisord.so"
+ const val SUPERVISORCTL = "libsupervisorctl.so"
+
+ fun dir(context: Context): File =
+ File(context.applicationInfo.nativeLibraryDir)
+
+ fun file(context: Context, name: String): File =
+ File(dir(context), name)
+
+ fun require(context: Context, name: String): File {
+ val f = file(context, name)
+ if (!f.isFile) {
+ throw IllegalStateException("Native binary missing: ${f.absolutePath}")
+ }
+ return f
+ }
+}
diff --git a/app/src/main/java/com/dccbigfred/android/server/ProcessOrphanReaper.kt b/app/src/main/java/com/dccbigfred/android/server/ProcessOrphanReaper.kt
new file mode 100644
index 0000000..394ffa7
--- /dev/null
+++ b/app/src/main/java/com/dccbigfred/android/server/ProcessOrphanReaper.kt
@@ -0,0 +1,70 @@
+package com.dccbigfred.android.server
+
+import android.system.Os
+import android.system.OsConstants
+import java.io.File
+import java.net.InetSocketAddress
+import java.net.Socket
+
+/**
+ * Cleans up orphaned loco-server / valkey processes left after force-stop.
+ * Verifies /proc//cmdline before signaling to avoid PID reuse kills.
+ */
+object ProcessOrphanReaper {
+
+ fun reap(pidFile: File, cmdlineNeedle: String) {
+ val pid = readPid(pidFile) ?: return
+ if (!cmdlineMatches(pid, cmdlineNeedle)) {
+ pidFile.delete()
+ return
+ }
+ signal(pid, OsConstants.SIGTERM)
+ val deadline = System.currentTimeMillis() + 3_000
+ while (System.currentTimeMillis() < deadline) {
+ if (!isAlive(pid)) break
+ Thread.sleep(100)
+ }
+ if (isAlive(pid)) {
+ signal(pid, OsConstants.SIGKILL)
+ }
+ pidFile.delete()
+ }
+
+ fun isPortOpen(host: String, port: Int, timeoutMs: Int = 300): Boolean =
+ try {
+ Socket().use { s ->
+ s.connect(InetSocketAddress(host, port), timeoutMs)
+ true
+ }
+ } catch (_: Exception) {
+ false
+ }
+
+ fun readPid(pidFile: File): Int? {
+ if (!pidFile.isFile) return null
+ return pidFile.readText().trim().toIntOrNull()?.takeIf { it > 0 }
+ }
+
+ fun writePid(pidFile: File, pid: Long) {
+ pidFile.parentFile?.mkdirs()
+ pidFile.writeText(pid.toString())
+ }
+
+ fun cmdlineMatches(pid: Int, needle: String): Boolean {
+ val cmdline = File("/proc/$pid/cmdline")
+ if (!cmdline.isFile) return false
+ val text = cmdline.readBytes().toString(Charsets.UTF_8).replace('\u0000', ' ')
+ return text.contains(needle)
+ }
+
+ fun isAlive(pid: Int): Boolean =
+ File("/proc/$pid").isDirectory
+
+ private fun signal(pid: Int, sig: Int) {
+ try {
+ Os.kill(pid, sig)
+ } catch (_: Exception) {
+ // already gone
+ }
+ }
+}
diff --git a/app/src/main/java/com/dccbigfred/android/ui/discovery/DiscoveryScreen.kt b/app/src/main/java/com/dccbigfred/android/ui/discovery/DiscoveryScreen.kt
index 8fb4dc2..bb8f3d0 100644
--- a/app/src/main/java/com/dccbigfred/android/ui/discovery/DiscoveryScreen.kt
+++ b/app/src/main/java/com/dccbigfred/android/ui/discovery/DiscoveryScreen.kt
@@ -22,6 +22,7 @@ import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ExpandLess
import androidx.compose.material.icons.filled.ExpandMore
+import androidx.compose.material.icons.filled.PhoneAndroid
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
@@ -63,6 +64,9 @@ import kotlinx.coroutines.launch
@Composable
fun DiscoveryScreen(
onServerSelected: (String) -> Unit,
+ localServerRunning: Boolean = false,
+ onOpenLocalStatus: () -> Unit = {},
+ onOpenLocalIntro: () -> Unit = {},
) {
val context = LocalContext.current
val discovery = remember { ServerDiscovery(context) }
@@ -75,6 +79,7 @@ fun DiscoveryScreen(
var manualError by remember { mutableStateOf(null) }
var manualBusy by remember { mutableStateOf(false) }
var manualExpanded by remember { mutableStateOf(false) }
+ var phoneExpanded by remember { mutableStateOf(false) }
val errorHostRequired = stringResource(R.string.discovery_error_host_required)
val errorUnreachable = stringResource(R.string.discovery_error_unreachable)
@@ -181,6 +186,54 @@ fun DiscoveryScreen(
}
}
+ // Simplified on-phone hub — same expandable style as Manual, above it.
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clickable { phoneExpanded = !phoneExpanded }
+ .padding(vertical = 8.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Text(
+ text = stringResource(R.string.discovery_phone_section),
+ style = MaterialTheme.typography.titleMedium,
+ modifier = Modifier.weight(1f),
+ )
+ Icon(
+ imageVector = if (phoneExpanded) {
+ Icons.Default.ExpandLess
+ } else {
+ Icons.Default.ExpandMore
+ },
+ contentDescription = null,
+ )
+ }
+ AnimatedVisibility(visible = phoneExpanded) {
+ Column {
+ if (localServerRunning) {
+ ListItem(
+ headlineContent = {
+ Text(stringResource(R.string.discovery_on_phone_running))
+ },
+ leadingContent = {
+ Icon(Icons.Default.PhoneAndroid, contentDescription = null)
+ },
+ modifier = Modifier.clickable(onClick = onOpenLocalStatus),
+ )
+ } else {
+ Button(
+ onClick = onOpenLocalIntro,
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ Icon(Icons.Default.PhoneAndroid, contentDescription = null)
+ Spacer(modifier = Modifier.width(8.dp))
+ Text(stringResource(R.string.discovery_on_phone_builtin))
+ }
+ }
+ Spacer(modifier = Modifier.height(8.dp))
+ }
+ }
+
Row(
modifier = Modifier
.fillMaxWidth()
diff --git a/app/src/main/java/com/dccbigfred/android/ui/localserver/LocalServerIntroScreen.kt b/app/src/main/java/com/dccbigfred/android/ui/localserver/LocalServerIntroScreen.kt
new file mode 100644
index 0000000..3b7c058
--- /dev/null
+++ b/app/src/main/java/com/dccbigfred/android/ui/localserver/LocalServerIntroScreen.kt
@@ -0,0 +1,122 @@
+package com.dccbigfred.android.ui.localserver
+
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material3.Button
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedButton
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import com.dccbigfred.android.R
+import com.dccbigfred.android.ui.components.topAppBarEdgePadding
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun LocalServerIntroScreen(
+ onBack: () -> Unit,
+ onConfirmStart: () -> Unit,
+) {
+ Scaffold(
+ topBar = {
+ TopAppBar(
+ modifier = Modifier.topAppBarEdgePadding(),
+ title = { Text(stringResource(R.string.local_server_intro_title)) },
+ navigationIcon = {
+ IconButton(onClick = onBack) {
+ Icon(
+ Icons.AutoMirrored.Filled.ArrowBack,
+ contentDescription = stringResource(R.string.back),
+ )
+ }
+ },
+ )
+ },
+ ) { padding ->
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(padding)
+ .padding(horizontal = 16.dp)
+ .verticalScroll(rememberScrollState()),
+ ) {
+ Spacer(modifier = Modifier.height(8.dp))
+ Image(
+ painter = painterResource(R.drawable.yd7001_bigfred_network),
+ contentDescription = stringResource(R.string.local_server_intro_diagram_cd),
+ contentScale = ContentScale.FillWidth,
+ modifier = Modifier
+ .fillMaxWidth()
+ .clip(RoundedCornerShape(8.dp)),
+ )
+ Spacer(modifier = Modifier.height(16.dp))
+ Text(
+ text = stringResource(R.string.local_server_intro_body),
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.onSurface,
+ )
+ Spacer(modifier = Modifier.height(24.dp))
+ Button(
+ onClick = onConfirmStart,
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ Text(stringResource(R.string.local_server_intro_confirm))
+ }
+ Spacer(modifier = Modifier.height(8.dp))
+ OutlinedButton(
+ onClick = onBack,
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ Text(stringResource(R.string.local_server_intro_back))
+ }
+ Spacer(modifier = Modifier.height(16.dp))
+ Surface(
+ modifier = Modifier.fillMaxWidth(),
+ shape = RoundedCornerShape(8.dp),
+ color = MaterialTheme.colorScheme.errorContainer,
+ ) {
+ Text(
+ text = stringResource(R.string.local_server_intro_multi_warning),
+ modifier = Modifier.padding(12.dp),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onErrorContainer,
+ )
+ }
+ Spacer(modifier = Modifier.height(12.dp))
+ Surface(
+ modifier = Modifier.fillMaxWidth(),
+ shape = RoundedCornerShape(8.dp),
+ color = MaterialTheme.colorScheme.primaryContainer,
+ ) {
+ Text(
+ text = stringResource(R.string.local_server_intro_orange_bar_info),
+ modifier = Modifier.padding(12.dp),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onPrimaryContainer,
+ )
+ }
+ Spacer(modifier = Modifier.height(16.dp))
+ }
+ }
+}
diff --git a/app/src/main/java/com/dccbigfred/android/ui/localserver/LocalServerStatusScreen.kt b/app/src/main/java/com/dccbigfred/android/ui/localserver/LocalServerStatusScreen.kt
new file mode 100644
index 0000000..ecb23c0
--- /dev/null
+++ b/app/src/main/java/com/dccbigfred/android/ui/localserver/LocalServerStatusScreen.kt
@@ -0,0 +1,182 @@
+package com.dccbigfred.android.ui.localserver
+
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.ExpandLess
+import androidx.compose.material.icons.filled.ExpandMore
+import androidx.compose.material3.Button
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedButton
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import com.dccbigfred.android.R
+import com.dccbigfred.android.server.LocalServerPaths
+import com.dccbigfred.android.server.LocalServerState
+import com.dccbigfred.android.server.LocoServerService
+import com.dccbigfred.android.ui.components.topAppBarEdgePadding
+import java.io.File
+import kotlinx.coroutines.delay
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun LocalServerStatusScreen(
+ onOpenApp: () -> Unit,
+ onStopped: () -> Unit,
+) {
+ val context = LocalContext.current
+ val state by LocoServerService.state.collectAsStateWithLifecycle()
+ var logsExpanded by remember { mutableStateOf(false) }
+ var logTick by remember { mutableIntStateOf(0) }
+ LaunchedEffect(state) {
+ if (state is LocalServerState.Starting) {
+ while (LocoServerService.state.value is LocalServerState.Starting) {
+ delay(1_000)
+ logTick++
+ }
+ }
+ logTick++
+ }
+ val logs = remember(state, logTick) {
+ val dir = LocalServerPaths.from(context).logsDir
+ listOf("loco-server.log", "valkey.log", "supervisord.log")
+ .map { File(dir, it) }
+ .filter { it.isFile }
+ .joinToString("\n\n") { f ->
+ "--- ${f.name} ---\n" + f.readLines().takeLast(40).joinToString("\n")
+ }
+ .ifBlank { "" }
+ }
+
+ Scaffold(
+ topBar = {
+ TopAppBar(
+ modifier = Modifier.topAppBarEdgePadding(),
+ title = { Text(stringResource(R.string.local_server_status_title)) },
+ )
+ },
+ ) { padding ->
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(padding)
+ .padding(16.dp)
+ .verticalScroll(rememberScrollState()),
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ if (state is LocalServerState.Starting) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ CircularProgressIndicator(modifier = Modifier.size(22.dp), strokeWidth = 2.dp)
+ Spacer(modifier = Modifier.width(12.dp))
+ Text(
+ text = stringResource(R.string.discovery_on_phone_starting),
+ style = MaterialTheme.typography.bodyLarge,
+ )
+ }
+ } else {
+ Text(
+ text = when (val s = state) {
+ LocalServerState.Stopped -> stringResource(R.string.local_server_status_stopped)
+ LocalServerState.Starting -> stringResource(R.string.local_server_status_starting)
+ is LocalServerState.Running ->
+ stringResource(R.string.local_server_status_running, s.baseUrl)
+ is LocalServerState.Failed ->
+ stringResource(R.string.local_server_status_failed, s.message)
+ },
+ style = MaterialTheme.typography.bodyLarge,
+ color = if (state is LocalServerState.Failed) {
+ MaterialTheme.colorScheme.error
+ } else {
+ MaterialTheme.colorScheme.onSurface
+ },
+ )
+ }
+
+ if (state is LocalServerState.Running) {
+ Button(
+ onClick = onOpenApp,
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ Text(stringResource(R.string.local_server_open_app))
+ }
+ }
+
+ OutlinedButton(
+ onClick = { LocoServerService.restart(context) },
+ modifier = Modifier.fillMaxWidth(),
+ enabled = state !is LocalServerState.Starting,
+ ) {
+ Text(stringResource(R.string.local_server_restart))
+ }
+
+ Button(
+ onClick = {
+ LocoServerService.stop(context)
+ onStopped()
+ },
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ Text(stringResource(R.string.local_server_stop))
+ }
+
+ Spacer(modifier = Modifier.height(8.dp))
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clickable { logsExpanded = !logsExpanded }
+ .padding(vertical = 4.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Text(
+ text = stringResource(R.string.local_server_logs),
+ style = MaterialTheme.typography.titleMedium,
+ modifier = Modifier.weight(1f),
+ )
+ Icon(
+ imageVector = if (logsExpanded) {
+ Icons.Default.ExpandLess
+ } else {
+ Icons.Default.ExpandMore
+ },
+ contentDescription = null,
+ )
+ }
+ AnimatedVisibility(visible = logsExpanded) {
+ Text(
+ text = logs.ifBlank { "—" },
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/dccbigfred/android/ui/navigation/BigFredApp.kt b/app/src/main/java/com/dccbigfred/android/ui/navigation/BigFredApp.kt
index 1bada0c..f4bfabd 100644
--- a/app/src/main/java/com/dccbigfred/android/ui/navigation/BigFredApp.kt
+++ b/app/src/main/java/com/dccbigfred/android/ui/navigation/BigFredApp.kt
@@ -29,6 +29,7 @@ import androidx.compose.material.icons.filled.NetworkCheck
import androidx.compose.material.icons.filled.PhoneAndroid
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Settings
+import androidx.compose.material3.AlertDialog
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DrawerValue
import androidx.compose.material3.HorizontalDivider
@@ -39,6 +40,7 @@ import androidx.compose.material3.ModalNavigationDrawer
import androidx.compose.material3.NavigationDrawerItem
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberDrawerState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
@@ -50,6 +52,7 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
+import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -69,11 +72,16 @@ import com.dccbigfred.android.MainActivity
import com.dccbigfred.android.R
import com.dccbigfred.android.data.ServerPreferences
import com.dccbigfred.android.locale.LocalePrefs
+import com.dccbigfred.android.models.ModelRow
import com.dccbigfred.android.network.CompanionServiceProbe
import com.dccbigfred.android.network.CompanionServices
+import com.dccbigfred.android.server.LocalServerState
+import com.dccbigfred.android.server.LocoServerService
import com.dccbigfred.android.ui.about.AboutScreen
import com.dccbigfred.android.ui.connection.ConnectionStatusScreen
import com.dccbigfred.android.ui.discovery.DiscoveryScreen
+import com.dccbigfred.android.ui.localserver.LocalServerIntroScreen
+import com.dccbigfred.android.ui.localserver.LocalServerStatusScreen
import com.dccbigfred.android.ui.models.ModelsCatalogScreen
import com.dccbigfred.android.ui.myvehicles.MyVehiclesScreen
import com.dccbigfred.android.ui.myvehicles.MyVehiclesViewModel
@@ -83,6 +91,8 @@ import com.dccbigfred.android.ui.webview.applyLocaleToWebView
import com.dccbigfred.android.ui.webview.deliverThrottleHardwareKeys
import com.dccbigfred.android.ui.webview.handleVolumeKeyEvent
import com.dccbigfred.android.wifi.LowLatencyWifiLock
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.launch
/** Hit target / drag strip at the physical left edge. */
@@ -93,11 +103,22 @@ private val DrawerHandleHeight = 56.dp
private const val DrawerOpenDragThresholdPx = 40f
@Composable
-fun BigFredApp() {
+fun BigFredApp(
+ openLocalWebViewRequests: Flow = emptyFlow(),
+) {
val context = LocalContext.current
val app = context.applicationContext as BigFredApplication
val prefs = app.serverPreferences
- val savedUrl by prefs.serverBaseUrl.collectAsStateWithLifecycle(initialValue = null)
+ // Distinguish "DataStore not read yet" from "no saved server" so bootstrap
+ // does not race into Discovery before the persisted URL arrives.
+ var prefsReady by remember { mutableStateOf(false) }
+ var savedUrl by remember { mutableStateOf(null) }
+ LaunchedEffect(prefs) {
+ prefs.serverBaseUrl.collect { url ->
+ savedUrl = url
+ prefsReady = true
+ }
+ }
val volumeKeysThrottleEnabled by prefs.volumeKeysThrottleEnabled
.collectAsStateWithLifecycle(initialValue = ServerPreferences.DEFAULT_VOLUME_KEYS_THROTTLE_ENABLED)
val navController = rememberNavController()
@@ -106,7 +127,8 @@ fun BigFredApp() {
val wifiLock = remember { LowLatencyWifiLock(context) }
val companionProbe = remember { CompanionServiceProbe() }
- var bootstrapped by remember { mutableStateOf(false) }
+ // Survive Activity recreation so a restored Nav back stack is not overwritten.
+ var bootstrapped by rememberSaveable { mutableStateOf(false) }
var activeUrl by remember { mutableStateOf(null) }
var spaWebView by remember { mutableStateOf(null) }
var throttleHardwareKeysActive by remember { mutableStateOf(false) }
@@ -139,15 +161,58 @@ fun BigFredApp() {
val selectedServerUrl = activeUrl ?: savedUrl
val selectedServerUrlLatest by rememberUpdatedState(selectedServerUrl)
+ val localServerState by LocoServerService.state.collectAsStateWithLifecycle()
+ var localStartError by remember { mutableStateOf(null) }
+
fun pushLocaleToSpa() {
applyLocaleToWebView(spaWebView, LocalePrefs.resolvedWebLocale())
}
- LaunchedEffect(Unit) {
- if (bootstrapped) return@LaunchedEffect
- navController.navigate(Routes.DISCOVERY) {
- popUpTo(Routes.BOOTSTRAP) { inclusive = true }
- }
+ LaunchedEffect(prefsReady, savedUrl) {
+ if (!prefsReady || bootstrapped) return@LaunchedEffect
bootstrapped = true
+ val url = savedUrl
+ if (url != null) {
+ // Stale phone-mode hub: URL was persisted while the FGS ran, but the
+ // process may have been killed / stopped from the notification.
+ if (LocoServerService.isLocalUrl(url) &&
+ localServerState !is LocalServerState.Running
+ ) {
+ prefs.clearServerBaseUrl()
+ activeUrl = null
+ navController.navigate(Routes.DISCOVERY) {
+ popUpTo(Routes.BOOTSTRAP) { inclusive = true }
+ }
+ } else {
+ activeUrl = url
+ navController.navigate(Routes.WEBVIEW) {
+ popUpTo(Routes.BOOTSTRAP) { inclusive = true }
+ }
+ }
+ } else {
+ navController.navigate(Routes.DISCOVERY) {
+ popUpTo(Routes.BOOTSTRAP) { inclusive = true }
+ }
+ }
+ }
+
+ // Clear persisted 127.0.0.1 when the local server stops (notification stop,
+ // OS kill after restart, crash) so the next launch does not open a dead WebView.
+ LaunchedEffect(localServerState) {
+ if (localServerState is LocalServerState.Running ||
+ localServerState is LocalServerState.Starting
+ ) {
+ return@LaunchedEffect
+ }
+ val url = activeUrl ?: savedUrl
+ if (!LocoServerService.isLocalUrl(url)) return@LaunchedEffect
+ prefs.clearServerBaseUrl()
+ activeUrl = null
+ if (currentRoute == Routes.WEBVIEW) {
+ navController.navigate(Routes.DISCOVERY) {
+ popUpTo(navController.graph.id) { inclusive = true }
+ launchSingleTop = true
+ }
+ }
}
LaunchedEffect(selectedServerUrl) {
@@ -178,6 +243,21 @@ fun BigFredApp() {
}
}
+ LaunchedEffect(Unit) {
+ openLocalWebViewRequests.collect {
+ when (val s = LocoServerService.state.value) {
+ is LocalServerState.Running -> goToWebView(s.baseUrl)
+ else -> {
+ // Server not ready yet — show the status screen instead of
+ // opening an unreachable WebView at 127.0.0.1:8080.
+ navController.navigate(Routes.LOCAL_SERVER) {
+ launchSingleTop = true
+ }
+ }
+ }
+ }
+ }
+
fun openBigFredApp() {
scope.launch {
drawerState.close()
@@ -217,11 +297,66 @@ fun BigFredApp() {
}
}
+ val notificationPermissionLauncher = androidx.activity.compose.rememberLauncherForActivityResult(
+ contract = androidx.activity.result.contract.ActivityResultContracts.RequestPermission(),
+ ) { /* proceed regardless — FGS may still start with silent notifications denied */ }
+
+ fun startLocalServer() {
+ scope.launch {
+ localStartError = null
+ if (android.os.Build.VERSION.SDK_INT >= 33) {
+ val granted = androidx.core.content.ContextCompat.checkSelfPermission(
+ context,
+ android.Manifest.permission.POST_NOTIFICATIONS,
+ ) == android.content.pm.PackageManager.PERMISSION_GRANTED
+ if (!granted) {
+ notificationPermissionLauncher.launch(android.Manifest.permission.POST_NOTIFICATIONS)
+ }
+ }
+ // Status screen shows Starting / Failed / Running while boot runs.
+ navController.navigate(Routes.LOCAL_SERVER) {
+ launchSingleTop = true
+ popUpTo(Routes.LOCAL_SERVER_INTRO) { inclusive = true }
+ }
+ LocoServerService.start(context)
+ val deadline = System.currentTimeMillis() + 60_000
+ while (System.currentTimeMillis() < deadline) {
+ when (val s = LocoServerService.state.value) {
+ is LocalServerState.Running -> {
+ goToWebView(s.baseUrl)
+ return@launch
+ }
+ is LocalServerState.Failed -> {
+ localStartError = s.message
+ return@launch
+ }
+ else -> kotlinx.coroutines.delay(300)
+ }
+ }
+ localStartError = "timeout"
+ }
+ }
+
+ fun stopLocalServerAndDiscover() {
+ scope.launch {
+ LocoServerService.stop(context)
+ prefs.clearServerBaseUrl()
+ activeUrl = null
+ navController.navigate(Routes.DISCOVERY) {
+ popUpTo(navController.graph.id) { inclusive = true }
+ launchSingleTop = true
+ }
+ drawerState.close()
+ }
+ }
+
fun logoutFromServer() {
scope.launch {
drawerState.close()
val url = selectedServerUrl
- if (url != null) {
+ if (LocoServerService.isLocalUrl(url)) {
+ LocoServerService.stop(context)
+ } else if (url != null) {
app.bigFredApiClient.logout(url)
}
prefs.clearServerBaseUrl()
@@ -259,6 +394,23 @@ fun BigFredApp() {
icon = { Icon(Icons.Default.Home, contentDescription = null) },
onClick = { openBigFredApp() },
)
+ if (localServerState is LocalServerState.Running ||
+ LocoServerService.isLocalUrl(selectedServerUrl)
+ ) {
+ NavigationDrawerItem(
+ label = { Text(stringResource(R.string.menu_local_server)) },
+ selected = currentRoute == Routes.LOCAL_SERVER,
+ icon = { Icon(Icons.Default.PhoneAndroid, contentDescription = null) },
+ onClick = {
+ scope.launch {
+ drawerState.close()
+ navController.navigate(Routes.LOCAL_SERVER) {
+ launchSingleTop = true
+ }
+ }
+ },
+ )
+ }
NavigationDrawerItem(
label = { Text(stringResource(R.string.menu_settings)) },
selected = currentRoute == Routes.SETTINGS,
@@ -298,7 +450,10 @@ fun BigFredApp() {
}
},
)
- if (selectedServerUrl != null) {
+ if (selectedServerUrl != null &&
+ localServerState !is LocalServerState.Running &&
+ !LocoServerService.isLocalUrl(selectedServerUrl)
+ ) {
NavigationDrawerItem(
label = { Text(stringResource(R.string.menu_connection_status)) },
selected = currentRoute == Routes.CONNECTION,
@@ -408,6 +563,9 @@ fun BigFredApp() {
onThrottleHardwareKeysActive = { active ->
throttleHardwareKeysActive = active
},
+ onRotateScreen = {
+ activity?.toggleScreenOrientation()
+ },
)
}
}
@@ -429,7 +587,32 @@ fun BigFredApp() {
}
}
composable(Routes.DISCOVERY) {
- DiscoveryScreen(onServerSelected = { url -> goToWebView(url) })
+ DiscoveryScreen(
+ onServerSelected = { url -> goToWebView(url) },
+ localServerRunning = localServerState is LocalServerState.Running,
+ onOpenLocalStatus = {
+ navController.navigate(Routes.LOCAL_SERVER) {
+ launchSingleTop = true
+ }
+ },
+ onOpenLocalIntro = {
+ navController.navigate(Routes.LOCAL_SERVER_INTRO) {
+ launchSingleTop = true
+ }
+ },
+ )
+ }
+ composable(Routes.LOCAL_SERVER_INTRO) {
+ LocalServerIntroScreen(
+ onBack = { navController.popBackStack() },
+ onConfirmStart = { startLocalServer() },
+ )
+ }
+ composable(Routes.LOCAL_SERVER) {
+ LocalServerStatusScreen(
+ onOpenApp = { openBigFredApp() },
+ onStopped = { stopLocalServerAndDiscover() },
+ )
}
composable(Routes.WEBVIEW) {
if (webSessionUrl == null) {
@@ -486,11 +669,12 @@ fun BigFredApp() {
ModelsCatalogScreen(
onBack = { navController.popBackStack() },
onAddToMyVehicles = { row ->
+ val entity = MyVehiclesViewModel.fromModelRow(
+ row,
+ uuid = java.util.UUID.randomUUID().toString(),
+ )
scope.launch {
- val repo = app.localVehicleRepository
- repo.upsert(
- MyVehiclesViewModel.fromModelRow(row, repo.newUuid()),
- )
+ app.localVehicleRepository.upsert(entity)
}
},
)
@@ -521,6 +705,21 @@ fun BigFredApp() {
}
}
}
+
+ if (localStartError != null) {
+ AlertDialog(
+ onDismissRequest = { localStartError = null },
+ title = { Text(stringResource(R.string.discovery_on_phone_title)) },
+ text = {
+ Text(stringResource(R.string.discovery_on_phone_failed, localStartError!!))
+ },
+ confirmButton = {
+ TextButton(onClick = { localStartError = null }) {
+ Text(stringResource(R.string.discovery_on_phone_cancel))
+ }
+ },
+ )
+ }
}
@Composable
diff --git a/app/src/main/java/com/dccbigfred/android/ui/navigation/Routes.kt b/app/src/main/java/com/dccbigfred/android/ui/navigation/Routes.kt
index b7b448c..0d74af9 100644
--- a/app/src/main/java/com/dccbigfred/android/ui/navigation/Routes.kt
+++ b/app/src/main/java/com/dccbigfred/android/ui/navigation/Routes.kt
@@ -9,4 +9,6 @@ object Routes {
const val MODELS = "models"
const val MY_VEHICLES = "my_vehicles"
const val ABOUT = "about"
+ const val LOCAL_SERVER = "local_server"
+ const val LOCAL_SERVER_INTRO = "local_server_intro"
}
diff --git a/app/src/main/java/com/dccbigfred/android/ui/webview/BigFredJsBridge.kt b/app/src/main/java/com/dccbigfred/android/ui/webview/BigFredJsBridge.kt
index 7821635..954df1d 100644
--- a/app/src/main/java/com/dccbigfred/android/ui/webview/BigFredJsBridge.kt
+++ b/app/src/main/java/com/dccbigfred/android/ui/webview/BigFredJsBridge.kt
@@ -10,6 +10,7 @@ import com.dccbigfred.android.locale.LocalePrefs
class BigFredJsBridge(
private val onOpenModelPicker: () -> Unit,
private val onThrottleHardwareKeysActive: (Boolean) -> Unit = {},
+ private val onRotateScreen: () -> Unit = {},
) {
@JavascriptInterface
fun openModelPicker() {
@@ -29,4 +30,10 @@ class BigFredJsBridge(
fun setThrottleHardwareKeysActive(active: Boolean) {
onThrottleHardwareKeysActive(active)
}
+
+ /** Toggle locked portrait/landscape (manual — no sensor auto-rotate). */
+ @JavascriptInterface
+ fun rotateScreen() {
+ onRotateScreen()
+ }
}
diff --git a/app/src/main/java/com/dccbigfred/android/ui/webview/BigFredWebViewScreen.kt b/app/src/main/java/com/dccbigfred/android/ui/webview/BigFredWebViewScreen.kt
index 32c38bc..b8935aa 100644
--- a/app/src/main/java/com/dccbigfred/android/ui/webview/BigFredWebViewScreen.kt
+++ b/app/src/main/java/com/dccbigfred/android/ui/webview/BigFredWebViewScreen.kt
@@ -32,6 +32,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
+import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -70,10 +71,15 @@ fun BigFredWebViewScreen(
baseUrl: String,
onWebViewReady: ((WebView?) -> Unit)? = null,
onThrottleHardwareKeysActive: ((Boolean) -> Unit)? = null,
+ onRotateScreen: (() -> Unit)? = null,
) {
// Freeze the URL for this WebView session so DataStore / nav recompositions
// with the same address do not trigger a reload (which would churn the WS).
- val sessionUrl = remember(baseUrl) { baseUrl.trimEnd('/') + "/" }
+ val sessionBase = remember(baseUrl) { baseUrl.trimEnd('/') }
+ val sessionUrl = remember(sessionBase) { "$sessionBase/" }
+ // Survive unavoidable Activity recreation (e.g. process death) so we can
+ // reload /throttle instead of always starting at "/".
+ var lastSpaUrl by rememberSaveable(sessionBase) { mutableStateOf(null) }
var webView by remember { mutableStateOf(null) }
var loading by remember { mutableStateOf(true) }
var loadError by remember { mutableStateOf(null) }
@@ -82,8 +88,16 @@ fun BigFredWebViewScreen(
val openPicker by rememberUpdatedState(newValue = { pickerVisible = true })
val onReady by rememberUpdatedState(onWebViewReady)
val onHardwareKeysActive by rememberUpdatedState(onThrottleHardwareKeysActive)
+ val onRotate by rememberUpdatedState(onRotateScreen)
val lifecycleOwner = LocalLifecycleOwner.current
+ fun rememberSpaUrl(url: String?) {
+ if (url.isNullOrBlank()) return
+ if (url == "about:blank") return
+ if (!url.startsWith(sessionBase)) return
+ lastSpaUrl = url
+ }
+
BackHandler(enabled = pickerVisible) {
deliverModelPickResult(webView, null)
pickerVisible = false
@@ -179,6 +193,9 @@ fun BigFredWebViewScreen(
onThrottleHardwareKeysActive = { active ->
post { onHardwareKeysActive?.invoke(active) }
},
+ onRotateScreen = {
+ post { onRotate?.invoke() }
+ },
),
"BigFredNativeApp",
)
@@ -195,6 +212,7 @@ fun BigFredWebViewScreen(
override fun onPageFinished(view: WebView, url: String?) {
loading = false
canGoBack = view.canGoBack()
+ rememberSpaUrl(url)
view.requestFocus()
applyLocaleToWebView(view, LocalePrefs.resolvedWebLocale())
}
@@ -206,6 +224,7 @@ fun BigFredWebViewScreen(
) {
loading = true
loadError = null
+ rememberSpaUrl(url)
// Navigation clears SPA handlers; reclaim system volume until
// a throttle surface re-registers.
onHardwareKeysActive?.invoke(false)
@@ -217,6 +236,7 @@ fun BigFredWebViewScreen(
isReload: Boolean,
) {
canGoBack = view.canGoBack()
+ rememberSpaUrl(url)
}
override fun onReceivedError(
@@ -268,7 +288,14 @@ fun BigFredWebViewScreen(
}
// Tag before load so the first update{} pass does not reload.
tag = sessionUrl
- loadUrl(sessionUrl)
+ val restored = lastSpaUrl
+ val initialUrl =
+ if (restored != null && restored.startsWith(sessionBase)) {
+ restored
+ } else {
+ sessionUrl
+ }
+ loadUrl(initialUrl)
webView = this
onReady?.invoke(this)
requestFocus()
diff --git a/app/src/main/res/drawable/yd7001_bigfred_network.png b/app/src/main/res/drawable/yd7001_bigfred_network.png
new file mode 100644
index 0000000..3412692
Binary files /dev/null and b/app/src/main/res/drawable/yd7001_bigfred_network.png differ
diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml
index d0f6086..1cc6d32 100644
--- a/app/src/main/res/values-de/strings.xml
+++ b/app/src/main/res/values-de/strings.xml
@@ -132,6 +132,36 @@
Subnetz .120
manuell
+
+ Vereinfachter Server auf dem Telefon
+ Eingebautes BigFred auf diesem Telefon
+ BigFred auf dem Telefon
+ BigFred auf dem Telefon (aktiv)
+ BigFred wird auf diesem Telefon gestartet…
+ Lokales BigFred konnte nicht gestartet werden: %1$s
+ OK
+ Eingebautes BigFred auf diesem Telefon
+ Typisches BigFred-Hub-Netzwerk: Zentrale, Router und BigFred
+ BigFred ist als zentraler Hub gedacht — ein physisches Gerät neben der Zentrale, an das sich Telefone, WlanMäuse, WiFreds und LongFreds anbinden. Durch die Zentralisierung können mehrere Nutzer gemeinsam fahren; Funktionen wie Totmann und Notbremsung bei Reichweitenverlust sind möglich.\n\nWenn Sie die Hub-Software als BigFred auf dem Telefon starten, erhalten Sie eine funktions- und sicherheitsreduzierte Variante. Sie kann keinen Zug notbremsen, wenn der Akku leer ist, Sie können kein zweites Telefon anbinden und keine WlanMaus, keinen WiFred und keinen LongFred anschließen. Betrachten Sie das als Demo vor dem Kauf von Hardware für BigFred.
+ Zurück
+ Verstanden, trotzdem starten
+ Schließen Sie nicht mehrere vereinfachte BigFred-Instanzen gleichzeitig an dieselbe Anlage an. Ohne zentrales physisches Gerät nutzen Sie BigFred jeweils nur auf einem Telefon.
+ Der vereinfachte BigFred hat eine orangefarbene obere Leiste, um ihn vom BigFred auf einem zentralen physischen Gerät zu unterscheiden.
+ BigFred auf dem Telefon: aktiv
+ BigFred auf dem Telefon
+ Server stoppen
+ Server neu starten
+ BigFred öffnen
+ Aktuelle Protokolle
+ Gestoppt
+ Startet…
+ Läuft unter %1$s
+ Fehler: %1$s
+ Lokaler BigFred-Server
+ BigFred auf dem Telefon
+ Tippen, um BigFred zu öffnen
+ Native Binärdateien fehlen. make native-prebuilt ausführen (loco-server aus GHCR + Valkey/supervisord-Releases) oder loco lokal in ../bigfred bauen.
+
Einstellungen
Sprache
diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml
index fafca1a..c17890e 100644
--- a/app/src/main/res/values-pl/strings.xml
+++ b/app/src/main/res/values-pl/strings.xml
@@ -132,6 +132,36 @@
podsieć .120
ręczny
+
+ Uproszczony serwer na telefonie
+ Wbudowany BigFred na tym telefonie
+ BigFred na telefonie
+ BigFred na telefonie (działa)
+ Uruchamianie BigFred na tym telefonie…
+ Nie udało się uruchomić lokalnego BigFred: %1$s
+ OK
+ Wbudowany BigFred na tym telefonie
+ Typowa sieć huba BigFred: centralka, router i BigFred
+ BigFred to z założenia centralny hub, fizyczne urządzenie leżące obok centralki, do którego podłączają się telefony, wlanmausy, wifredy, longfredy. Dzięki centralizacji hub umożliwia sterowanie wielu użytkownikom oraz posiada takie funkcjonalności jak np. czuwak i awaryjne hamowanie przy utracie zasięgu.\n\nUruchamiając oprogramowanie huba — BigFred na telefonie — otrzymasz okrojoną z funkcji i zabezpieczeń wersję. Nie będzie ona w stanie awaryjnie zahamować składu, gdy rozładuje Ci się telefon, nie podłączysz do niej drugiego telefonu, nie podłączysz Wlanmausa, WiFreda ani LongFreda. Potraktuj to jako wersję pokazową przed zakupem urządzenia do uruchomienia BigFreda.
+ Cofnij
+ Rozumiem, uruchom pomimo tego
+ Nie należy do tej samej makiety podłączać wielu uproszczonych BigFredów na raz. Bez centralnego, fizycznego urządzenia korzystaj z BigFred tylko na jednym telefonie w jednym czasie
+ Uproszczony BigFred ma pomarańczową górną belkę dla odróżnienia od BigFreda, który jest uruchamiany na fizycznym, centralnym urządzeniu
+ BigFred na telefonie: działa
+ BigFred na telefonie
+ Zatrzymaj serwer
+ Restartuj serwer
+ Otwórz BigFred
+ Ostatnie logi
+ Zatrzymany
+ Uruchamianie…
+ Działa pod %1$s
+ Błąd: %1$s
+ Lokalny serwer BigFred
+ BigFred na telefonie
+ Kliknij aby przejść do BigFred
+ Brak binarek natywnych. Uruchom make native-prebuilt (loco-server z GHCR + release Valkey/supervisord) lub zbuduj loco lokalnie w ../bigfred.
+
Ustawienia
Język
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 8277a72..20c3b27 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -132,6 +132,36 @@
subnet .120
manual
+
+ Simplified server on phone
+ Built-in BigFred on this phone
+ BigFred on phone
+ BigFred on phone (running)
+ Starting BigFred on this phone…
+ Failed to start local BigFred: %1$s
+ OK
+ Built-in BigFred on this phone
+ Typical BigFred hub network: command station, router and BigFred
+ BigFred is designed as a central hub — a physical device next to the command station that phones, WlanMaus units, WiFreds and LongFreds connect to. Centralisation lets multiple users drive together and enables features such as dead-man protection and emergency braking when radio range is lost.\n\nRunning hub software as BigFred on a phone gives you a cut-down build with fewer features and safeguards. It cannot emergency-brake a train if your phone battery dies, you cannot connect a second phone, and you cannot attach a WlanMaus, WiFred or LongFred. Treat it as a demo before buying hardware to run BigFred properly.
+ Back
+ I understand — start anyway
+ Do not connect multiple simplified BigFred instances to the same layout at once. Without a central physical device, use BigFred on only one phone at a time.
+ Simplified BigFred has an orange top bar to distinguish it from BigFred running on a central physical device.
+ BigFred on phone: running
+ BigFred on phone
+ Stop server
+ Restart server
+ Open BigFred
+ Recent logs
+ Stopped
+ Starting…
+ Running at %1$s
+ Failed: %1$s
+ Local BigFred server
+ BigFred on phone
+ Tap to open BigFred
+ Native binaries missing. Run make native-prebuilt (GHCR loco-server + Valkey/supervisord releases) or build loco locally in ../bigfred.
+
Settings
Language
diff --git a/app/src/test/java/com/dccbigfred/android/server/ProcessOrphanReaperTest.kt b/app/src/test/java/com/dccbigfred/android/server/ProcessOrphanReaperTest.kt
new file mode 100644
index 0000000..4e021ae
--- /dev/null
+++ b/app/src/test/java/com/dccbigfred/android/server/ProcessOrphanReaperTest.kt
@@ -0,0 +1,31 @@
+package com.dccbigfred.android.server
+
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import java.io.File
+
+class ProcessOrphanReaperTest {
+ @Test
+ fun readPid_missingFile_returnsNull() {
+ val f = File.createTempFile("pid", ".txt")
+ f.delete()
+ assertTrue(ProcessOrphanReaper.readPid(f) == null)
+ }
+
+ @Test
+ fun writeAndReadPid_roundTrip() {
+ val f = File.createTempFile("pid", ".txt")
+ f.deleteOnExit()
+ ProcessOrphanReaper.writePid(f, 4242)
+ assertTrue(ProcessOrphanReaper.readPid(f) == 4242)
+ }
+
+ @Test
+ fun isLocalUrl_detectsLoopback() {
+ assertTrue(LocoServerService.isLocalUrl("http://127.0.0.1:8080"))
+ assertTrue(LocoServerService.isLocalUrl("http://localhost:8080"))
+ assertFalse(LocoServerService.isLocalUrl("http://192.168.0.120:8080"))
+ assertFalse(LocoServerService.isLocalUrl(null))
+ }
+}
diff --git a/gradle.properties b/gradle.properties
index f0a2e55..56b77c9 100644
--- a/gradle.properties
+++ b/gradle.properties
@@ -2,3 +2,13 @@ org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
android.useAndroidX=true
kotlin.code.style=official
android.nonTransitiveRClass=true
+
+# Native loco-server for "BigFred on phone" (GHCR OCI via ORAS).
+# Prefer local ../bigfred/bin/loco-server-android-arm64, then native-prebuilt/,
+# then pull from GHCR (:main or release tag via bigfredOciTag).
+bigfredOciImage=ghcr.io/dcc-bigfred/loco-server-android-arm64
+bigfredOciTag=main
+
+# Valkey / supervisord Android prebuilts (latest GitHub release of each deps repo).
+depsAndroidValkeyRepo=dcc-bigfred/deps-android-valkey
+depsAndroidSupervisordRepo=dcc-bigfred/deps-android-supervisord
diff --git a/scripts/fetch-ghcr-oras.sh b/scripts/fetch-ghcr-oras.sh
new file mode 100755
index 0000000..289b8d2
--- /dev/null
+++ b/scripts/fetch-ghcr-oras.sh
@@ -0,0 +1,62 @@
+#!/usr/bin/env bash
+# Pull loco-server-android-arm64 from GHCR (ORAS) and write libloco-server.so.
+# Usage: fetch-ghcr-oras.sh [fallback-tag ...]
+#
+# Auth (optional; needed for private packages):
+# GITHUB_TOKEN / GH_TOKEN / BIGFRED_NATIVE_TOKEN
+set -euo pipefail
+
+IMAGE="${1:?usage: $0 [fallback-tag ...]}"
+TAG="${2:?}"
+OUT="${3:?}"
+shift 3
+FALLBACK_TAGS=("$@")
+
+TOKEN="${BIGFRED_NATIVE_TOKEN:-${GH_TOKEN:-${GITHUB_TOKEN:-}}}"
+if [[ -n "${TOKEN}" ]]; then
+ USER="${GITHUB_ACTOR:-oauth2}"
+ echo "${TOKEN}" | oras login ghcr.io -u "${USER}" --password-stdin >/dev/null
+fi
+
+tmpdir="$(mktemp -d)"
+cleanup() { rm -rf "${tmpdir}"; }
+trap cleanup EXIT
+
+pull_tag() {
+ local t="$1"
+ rm -rf "${tmpdir:?}"/*
+ mkdir -p "${tmpdir}"
+ echo "Pulling ${IMAGE}:${t}…"
+ oras pull "${IMAGE}:${t}" -o "${tmpdir}"
+}
+
+if ! pull_tag "${TAG}"; then
+ pulled=0
+ for fb in "${FALLBACK_TAGS[@]}"; do
+ if pull_tag "${fb}"; then
+ pulled=1
+ break
+ fi
+ done
+ if [[ "${pulled}" -eq 0 ]]; then
+ echo "error: could not pull ${IMAGE}:${TAG} (tried fallbacks: ${FALLBACK_TAGS[*]:-none})" >&2
+ exit 1
+ fi
+fi
+
+src="${tmpdir}/loco-server-android-arm64"
+if [[ ! -f "${src}" ]]; then
+ mapfile -t files < <(find "${tmpdir}" -type f ! -name 'manifest.json' ! -name 'config.json')
+ if [[ ${#files[@]} -eq 1 ]]; then
+ src="${files[0]}"
+ else
+ echo "error: expected loco-server-android-arm64 in OCI artifact, found:" >&2
+ find "${tmpdir}" -type f >&2
+ exit 1
+ fi
+fi
+
+mkdir -p "$(dirname "${OUT}")"
+cp -f "${src}" "${OUT}"
+chmod 755 "${OUT}"
+echo "Wrote ${OUT} ($(wc -c < "${OUT}") bytes) from ${IMAGE}:${TAG}"
diff --git a/scripts/fetch-github-release-asset.sh b/scripts/fetch-github-release-asset.sh
new file mode 100755
index 0000000..4d817a2
--- /dev/null
+++ b/scripts/fetch-github-release-asset.sh
@@ -0,0 +1,57 @@
+#!/usr/bin/env bash
+# Download a single asset from the latest GitHub release of a repo.
+# Usage: ./scripts/fetch-github-release-asset.sh
+#
+# Auth (optional, needed for private repos):
+# GITHUB_TOKEN / GH_TOKEN / BIGFRED_NATIVE_TOKEN
+set -euo pipefail
+
+REPO="${1:?usage: $0 }"
+ASSET="${2:?}"
+OUT="${3:?}"
+
+mkdir -p "$(dirname "${OUT}")"
+
+TOKEN="${BIGFRED_NATIVE_TOKEN:-${GH_TOKEN:-${GITHUB_TOKEN:-}}}"
+AUTH=()
+if [[ -n "${TOKEN}" ]]; then
+ AUTH=(-H "Authorization: Bearer ${TOKEN}")
+fi
+
+API="https://api.github.com/repos/${REPO}/releases/latest"
+echo "Resolving latest release of ${REPO}…"
+json="$(curl -fsSL "${AUTH[@]}" -H "Accept: application/vnd.github+json" "${API}")"
+
+tag="$(printf '%s' "${json}" | python3 -c 'import json,sys; print(json.load(sys.stdin).get("tag_name",""))')"
+url="$(printf '%s' "${json}" | python3 -c '
+import json,sys
+asset=sys.argv[1]
+data=json.load(sys.stdin)
+for a in data.get("assets") or []:
+ if a.get("name")==asset:
+ print(a.get("url") or "")
+ break
+' "${ASSET}")"
+
+if [[ -z "${url}" ]]; then
+ echo "error: asset '${ASSET}' not found in latest release of ${REPO} (tag=${tag:-unknown})" >&2
+ echo "Available assets:" >&2
+ printf '%s' "${json}" | python3 -c 'import json,sys; [print(" -",a.get("name")) for a in (json.load(sys.stdin).get("assets") or [])]' >&2
+ exit 1
+fi
+
+echo "Downloading ${ASSET} from ${REPO}@${tag}"
+tmp="$(mktemp)"
+cleanup() { rm -f "${tmp}"; }
+trap cleanup EXIT
+
+# Asset API URL requires Accept: application/octet-stream
+curl -fsSL "${AUTH[@]}" \
+ -H "Accept: application/octet-stream" \
+ -L \
+ -o "${tmp}" \
+ "${url}"
+
+cp -f "${tmp}" "${OUT}"
+chmod 755 "${OUT}"
+echo "Wrote ${OUT} ($(wc -c < "${OUT}") bytes) from ${REPO}@${tag}"