Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
9ce24b2
feat(android): BigFred on phone — local FGS server with Valkey/superv…
keskad Jul 29, 2026
7fae146
fix(android): surface local-server start failures and show progress
keskad Jul 29, 2026
54910f8
fix(android): require libloco-server.so and build it in CI from bigfred
keskad Jul 29, 2026
5a310f9
Pull loco-server from GHCR (ORAS) instead of building bigfred in CI.
keskad Jul 29, 2026
1e50f9e
fix(ci): always pull loco-server OCI tag main (no branch tags)
keskad Jul 29, 2026
3024ee0
fix(android): run supervisord from nativeLibraryDir, not code_cache
keskad Jul 29, 2026
91fe6b6
feat(android): set BIGFRED_LAN_PREFIX from NetworkInterface
keskad Jul 29, 2026
cb0c572
feat(android): redesign on-phone BigFred discovery and intro
keskad Jul 29, 2026
fe93618
feat(android): show hub network diagram on local BigFred intro
keskad Jul 29, 2026
c6af384
feat(android): improve local server notification and status UX
keskad Jul 29, 2026
41b9554
fix(android): harden local server lifecycle and restore catalog add
keskad Jul 29, 2026
0bcda8a
Merge branch 'main' into feat/bigfred-on-phone
keskad Jul 30, 2026
cc5da9e
fix(android): make local server restart atomic
keskad Jul 30, 2026
ae5c1e6
Keep WebView alive across rotation and restore SPA route on recreate.
keskad Jul 30, 2026
a5c0e80
Prefer local loco-server binary in make apk when available.
keskad Jul 30, 2026
e84f582
Clear stale local hub URL when phone-mode server is not running.
keskad Jul 30, 2026
70aaac0
Add multi-phone warning to built-in BigFred intro screen.
keskad Jul 30, 2026
927a891
Add orange app bar info notice to built-in BigFred intro.
keskad Jul 30, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ on:

permissions:
contents: read
packages: read

jobs:
test-and-build:
Expand Down Expand Up @@ -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: ""
Expand All @@ -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
Expand Down
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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/
82 changes: 72 additions & 10 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)"
Expand All @@ -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)"
Expand Down
129 changes: 129 additions & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -90,13 +96,136 @@ android {
resources {
excludes += "/META-INF/{AL2.0,LGPL2.1}"
}
jniLibs {
useLegacyPackaging = true
}
}

androidResources {
noCompress += "db"
}
}

// 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)
Expand Down
12 changes: 11 additions & 1 deletion app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />

<uses-feature
android:name="android.hardware.usb.host"
Expand All @@ -25,14 +28,21 @@
<activity
android:name=".MainActivity"
android:exported="true"
android:configChanges="orientation|screenSize|keyboard|keyboardHidden|navigation|locale|layoutDirection"
android:launchMode="singleTop"
android:screenOrientation="locked"
android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout|keyboard|keyboardHidden|navigation|locale|layoutDirection"
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>

<service
android:name=".server.LocoServerService"
android:exported="false"
android:foregroundServiceType="connectedDevice" />

<!-- Persist per-app locales on API ≤ 32 (AppCompat). -->
<service
android:name="androidx.appcompat.app.AppLocalesMetadataHolderService"
Expand Down
Loading
Loading