Android app (android/), with QR and username/password sign-in

f9e5135 · Gabriel Morell · 2026-10-28 22:42

81 files +3098 -0
Message
{commit_body(@commit)}

Files changed

modified .dockerignore
+1 −0
@@ -9,3 +9,4 @@ assets/f/js/app.js
9 9 README.md
10 10 Justfile
11 11 cargo-generate.toml
12 +android/
added .forgejo/workflows/android.yml
+137 −0
@@ -0,0 +1,137 @@
1 +# Publishes the Fieri Android client to the forge's own F-Droid
2 +# repository, the same way the forge publishes its own client.
3 +#
4 +# Needs two secrets, and skips cleanly without them rather than failing
5 +# a build for a package that isn't set up yet:
6 +#
7 +# ANDROID_KEYSTORE base64 of the release keystore (.jks)
8 +# ANDROID_KEYSTORE_PASS its password
9 +#
10 +# Generate one once, and keep it: the signing certificate is what
11 +# Android checks on every update, so losing it means every install has
12 +# to be uninstalled and redone by hand. See android/README.md.
13 +#
14 +# This is the APK signing key. It is NOT the F-Droid index key — that
15 +# one is per-owner, lives in the forge's OpenBao, and never comes near
16 +# CI.
17 +name: android
18 +on:
19 + push:
20 + branches: [master]
21 +
22 +jobs:
23 + android:
24 + runs-on: self-hosted
25 + steps:
26 + - uses: actions/checkout@v4
27 +
28 + - uses: docker/setup-buildx-action@v3
29 +
30 + # `vars.CI_FORGE_URL` is a forge built-in, but an older forge
31 + # build won't inject it. Fall back to what the runner already
32 + # knows, and say so plainly if neither resolves rather than
33 + # handing curl an empty URL.
34 + - name: Resolve the forge URL
35 + env:
36 + CI_FORGE_URL: ${{ vars.CI_FORGE_URL }}
37 + run: |
38 + set -euo pipefail
39 + forge_url=${CI_FORGE_URL:-${GITHUB_SERVER_URL:-}}
40 +
41 + if [ -z "$forge_url" ]; then
42 + echo "No forge URL available." >&2
43 + echo "Set a CI_FORGE_URL variable on this repository or its org," >&2
44 + echo "or upgrade the forge to a build that injects it." >&2
45 + exit 1
46 + fi
47 +
48 + echo "using forge URL: $forge_url"
49 + echo "FORGE_URL=$forge_url" >> "$GITHUB_ENV"
50 +
51 + # A secret can't be read in a job-level `if`, so the gate is a
52 + # step output that everything after checks.
53 + - name: Unpack the signing keystore
54 + id: keystore
55 + env:
56 + KEYSTORE: ${{ secrets.ANDROID_KEYSTORE }}
57 + KEYSTORE_PASS: ${{ secrets.ANDROID_KEYSTORE_PASS }}
58 + run: |
59 + set -euo pipefail
60 +
61 + if [ -z "${KEYSTORE:-}" ] || [ -z "${KEYSTORE_PASS:-}" ]; then
62 + echo "No ANDROID_KEYSTORE / ANDROID_KEYSTORE_PASS set — skipping."
63 + echo "An unsigned or debug-signed APK is worse than none: the"
64 + echo "debug key is shared by every developer, so publishing one"
65 + echo "would let anyone ship an 'update' to it."
66 + echo "skip=true" >> "$GITHUB_OUTPUT"
67 + exit 0
68 + fi
69 +
70 + umask 077
71 + printf '%s' "$KEYSTORE" | base64 -d > release.jks
72 + printf '%s' "$KEYSTORE_PASS" > keystore.pass
73 + echo "skip=false" >> "$GITHUB_OUTPUT"
74 +
75 + - name: Build and sign the APK
76 + if: steps.keystore.outputs.skip != 'true'
77 + env:
78 + RUN_NUMBER: ${{ github.run_number }}
79 + run: |
80 + set -euo pipefail
81 +
82 + # One stamp for the whole run, computed here rather than
83 + # inside Gradle, so every artifact a run produces agrees on
84 + # its version even if the build is retried a step at a time.
85 + stamp=$(date -u +%s)
86 + echo "version: 0.1.0-dev-$stamp (versionCode $RUN_NUMBER)"
87 +
88 + docker buildx build \
89 + --target android-export \
90 + --build-arg GITHUB_RUN_NUMBER="$RUN_NUMBER" \
91 + --build-arg SOURCE_DATE_EPOCH="$stamp" \
92 + --secret id=keystore,src=./release.jks \
93 + --secret id=keystore_password,src=./keystore.pass \
94 + --output type=local,dest=./apk \
95 + --progress=plain \
96 + --file android/Dockerfile android
97 +
98 + # The keystore is gone before anything else runs, so a later step
99 + # or a failure mid-job can't leave it on a persistent runner.
100 + - name: Shred the keystore
101 + if: always()
102 + run: shred -u release.jks keystore.pass 2>/dev/null || rm -f release.jks keystore.pass
103 +
104 + # `${{ github.token }}` is minted for this job and authorized to
105 + # publish to THIS repository's packages and nowhere else, so there
106 + # is no secret to create first.
107 + - name: Publish to the F-Droid repository
108 + if: steps.keystore.outputs.skip != 'true'
109 + env:
110 + REPOSITORY: ${{ github.repository }}
111 + TOKEN: ${{ github.token }}
112 + run: |
113 + set -euo pipefail
114 + curl --fail-with-body -sS \
115 + -u "ci:$TOKEN" \
116 + -H "Content-Type: application/vnd.android.package-archive" \
117 + --data-binary "@apk/app-release.apk" \
118 + "$FORGE_URL/pkg/fdroid/$REPOSITORY/upload"
119 + echo
120 +
121 + - name: How to install it
122 + if: steps.keystore.outputs.skip != 'true'
123 + env:
124 + OWNER: ${{ github.repository_owner }}
125 + run: |
126 + set -euo pipefail
127 + fingerprint=$(curl -fsSL "$FORGE_URL/pkg/_keys/fdroid/$OWNER")
128 +
129 + cat <<EOF
130 + Add this repository in F-Droid (Settings -> Repositories -> +):
131 +
132 + $FORGE_URL/pkg/fdroid/$OWNER/_all?fingerprint=$fingerprint
133 +
134 + One entry covers every Android app $OWNER publishes. The
135 + fingerprint pins the index signing certificate; F-Droid
136 + refuses an index signed by anything else.
137 + EOF
modified README.md
+22 −0
@@ -95,6 +95,28 @@ The same OSM place can be in several areas (it's unique per area), and area
95 95 names only need to be unique within your own list, since other people's
96 96 areas are private.
97 97
98 +## The Android app and the API
99 +
100 +`android/` is a Compose app (see `android/README.md`): areas, ranking,
101 +"I went", and lunch votes. It signs in either way:
102 +
103 +- **username and password**: `POST /api/auth/login` trades them for a
104 + token for that phone; or
105 +- **QR**: avatar menu → **Devices** → **Make a pairing QR** shows
106 + `fieri://pair?base=<server>&token=<token>` once, for the app to scan.
107 +
108 +Both mint the same kind of API token (`ff_…`, stored only as a SHA-256),
109 +listed and revocable on the Devices page; the app's sign-out revokes its
110 +own. The QR's server address is `PUBLIC_URL` when set, else the proxy or
111 +Host headers.
112 +
113 +`/api/*` takes `Authorization: Bearer <token>` and answers 401 rather than
114 +redirecting. Its endpoints (`src/handlers/api.rs`) return the same data as
115 +the web pages, built by the same functions and checked by the same
116 +permissions. The app's tests decode real responses from it
117 +(`android/app/src/test/resources/api/`); refresh those fixtures when the
118 +JSON changes.
119 +
98 120 ## Deciding together: outings
99 121
100 122 "Start a vote" on an area's planner opens an **outing**: a ballot of a few
added android/.gitignore
+14 −0
@@ -0,0 +1,14 @@
1 +# Gradle
2 +.gradle/
3 +build/
4 +local.properties
5 +
6 +# Android Studio
7 +.idea/
8 +*.iml
9 +.cxx/
10 +captures/
11 +
12 +# Misc
13 +*.hprof
14 +.DS_Store
added android/Dockerfile
+137 −0
@@ -0,0 +1,137 @@
1 +# Builds and signs the release APK.
2 +#
3 +# In a container rather than on the runner, so the runner needs docker
4 +# and nothing else, and a developer can reproduce exactly what CI did
5 +# without installing an SDK.
6 +#
7 +# docker buildx build \
8 +# --target android-export \
9 +# --secret id=keystore,src=./release.jks \
10 +# --secret id=keystore_password,src=./keystore.pass \
11 +# --output type=local,dest=./apk \
12 +# --progress=plain \
13 +# --file android/Dockerfile android
14 +#
15 +# Run from the repository root. The build context is `android`, not the
16 +# repository: the app needs nothing from outside it, and the root
17 +# .dockerignore in several of these repos excludes `android/` so the
18 +# service image doesn't drag the Gradle wrapper and SDK caches along.
19 +# With a repo-root context BuildKit is handed a tree with the app
20 +# filtered out of it, and the COPY below fails as a bare glob error
21 +# with no step log to explain itself.
22 +
23 +ARG JDK_IMAGE=eclipse-temurin:17-jdk
24 +
25 +FROM ${JDK_IMAGE} AS sdk
26 +
27 +# Pinned. `…_latest.zip` is a moving target under a cache that has no
28 +# way to notice it moved.
29 +ARG CMDLINE_TOOLS=commandlinetools-linux-11076708_latest.zip
30 +ARG ANDROID_PLATFORM=34
31 +ARG BUILD_TOOLS=34.0.0
32 +
33 +ENV ANDROID_HOME=/opt/android-sdk \
34 + DEBIAN_FRONTEND=noninteractive
35 +
36 +RUN apt-get update \
37 + && apt-get install -y --no-install-recommends unzip curl ca-certificates \
38 + && rm -rf /var/lib/apt/lists/*
39 +
40 +RUN mkdir -p "${ANDROID_HOME}/cmdline-tools" \
41 + && curl -fsSL "https://dl.google.com/android/repository/${CMDLINE_TOOLS}" -o /tmp/tools.zip \
42 + && unzip -q /tmp/tools.zip -d "${ANDROID_HOME}/cmdline-tools" \
43 + && mv "${ANDROID_HOME}/cmdline-tools/cmdline-tools" "${ANDROID_HOME}/cmdline-tools/latest" \
44 + && rm /tmp/tools.zip
45 +
46 +ENV PATH="${ANDROID_HOME}/cmdline-tools/latest/bin:${ANDROID_HOME}/platform-tools:${PATH}"
47 +
48 +# `yes |` because sdkmanager prompts per licence and there is no
49 +# non-interactive flag that covers all of them.
50 +RUN yes | sdkmanager --licenses > /dev/null \
51 + && sdkmanager --install \
52 + "platforms;android-${ANDROID_PLATFORM}" \
53 + "build-tools;${BUILD_TOOLS}" \
54 + "platform-tools" > /dev/null
55 +
56 +# ── dependencies ─────────────────────────────────────────────────────
57 +#
58 +# Build files only, so the ~200 MB of Gradle and Maven downloads cache
59 +# as one layer that survives every change to the Kotlin sources.
60 +
61 +FROM sdk AS deps
62 +
63 +WORKDIR /src
64 +COPY gradle ./gradle
65 +COPY gradlew settings.gradle.kts build.gradle.kts gradle.properties ./
66 +COPY app/build.gradle.kts ./app/
67 +
68 +RUN echo "sdk.dir=${ANDROID_HOME}" > local.properties
69 +
70 +# A task that resolves the whole dependency graph without needing the
71 +# sources. `--no-daemon` because a daemon outliving the RUN is just a
72 +# process the image has to reap.
73 +RUN ./gradlew --no-daemon :app:dependencies --configuration releaseRuntimeClasspath > /dev/null \
74 + || echo "dependency warm-up failed; the build below will fetch them"
75 +
76 +# ── build ────────────────────────────────────────────────────────────
77 +
78 +FROM deps AS build
79 +
80 +ARG BUILD_TOOLS=34.0.0
81 +
82 +# A container build inherits nothing from the runner's environment, so
83 +# the version has to be handed in explicitly. Without these, Gradle's
84 +# getenv lookups come back empty inside here and every run builds
85 +# versionCode 1 — the same filename with different bytes, which the
86 +# registry refuses with a 409.
87 +#
88 +# Declared in this stage rather than at the top so they don't invalidate
89 +# the SDK and dependency layers, which is most of the build time.
90 +ARG GITHUB_RUN_NUMBER
91 +ARG SOURCE_DATE_EPOCH
92 +ARG RELEASE_VERSION
93 +ENV GITHUB_RUN_NUMBER=${GITHUB_RUN_NUMBER} \
94 + SOURCE_DATE_EPOCH=${SOURCE_DATE_EPOCH} \
95 + RELEASE_VERSION=${RELEASE_VERSION}
96 +
97 +COPY app ./app
98 +
99 +RUN echo "building versionCode=${GITHUB_RUN_NUMBER:-1}" \
100 + && ./gradlew --no-daemon :app:assembleRelease
101 +
102 +# Sign as a separate step from Gradle so the keystore never reaches a
103 +# build script, a Gradle cache, or an image layer. BuildKit secrets are
104 +# mounted for the life of one RUN and are not committed.
105 +#
106 +# zipalign before apksigner, not after: aligning rewrites offsets, and
107 +# doing it to an already-signed APK invalidates the v2 signature that
108 +# covers them.
109 +#
110 +# No --key-pass. apksigner reads each `file:` password as a *sequential*
111 +# read from that file, so passing the same one-line file to both
112 +# --ks-pass and --key-pass makes the second read hit EOF:
113 +#
114 +# Failed to read Key "…" password: end of file reached
115 +#
116 +# Omitted, the key password defaults to the store password, which is
117 +# what PKCS12 keystores use anyway.
118 +RUN --mount=type=secret,id=keystore,target=/tmp/release.jks \
119 + --mount=type=secret,id=keystore_password,target=/tmp/keystore.pass \
120 + set -eu; \
121 + tools="${ANDROID_HOME}/build-tools/${BUILD_TOOLS}"; \
122 + unsigned=app/build/outputs/apk/release/app-release-unsigned.apk; \
123 + mkdir -p /out; \
124 + "${tools}/zipalign" -p -f 4 "$unsigned" /tmp/aligned.apk; \
125 + "${tools}/apksigner" sign \
126 + --ks /tmp/release.jks \
127 + --ks-pass "file:/tmp/keystore.pass" \
128 + --out /out/app-release.apk \
129 + /tmp/aligned.apk; \
130 + "${tools}/apksigner" verify --print-certs /out/app-release.apk
131 +
132 +# ── export ───────────────────────────────────────────────────────────
133 +#
134 +# FROM scratch, so `--output type=local` lands the APK and nothing else.
135 +
136 +FROM scratch AS android-export
137 +COPY --from=build /out/app-release.apk /
added android/README.md
+113 −0

Click to load diff…

added android/app/build.gradle.kts
+90 −0

Click to load diff…

added android/app/proguard-rules.pro
+10 −0

Click to load diff…

added android/app/src/main/AndroidManifest.xml
+41 −0

Click to load diff…

added android/app/src/main/java/org/neiam/fieri/app/MainActivity.kt
+157 −0

Click to load diff…

added android/app/src/main/java/org/neiam/fieri/app/data/Api.kt
+96 −0

Click to load diff…

added android/app/src/main/java/org/neiam/fieri/app/data/Models.kt
+175 −0

Click to load diff…

added android/app/src/main/java/org/neiam/fieri/app/data/PairUri.kt
+24 −0

Click to load diff…

added android/app/src/main/java/org/neiam/fieri/app/data/Stores.kt
+73 −0

Click to load diff…

added android/app/src/main/java/org/neiam/fieri/app/ui/AreaScreen.kt
+266 −0

Click to load diff…

added android/app/src/main/java/org/neiam/fieri/app/ui/AreasScreen.kt
+125 −0

Click to load diff…

added android/app/src/main/java/org/neiam/fieri/app/ui/Common.kt
+136 −0

Click to load diff…

added android/app/src/main/java/org/neiam/fieri/app/ui/OutingScreen.kt
+243 −0

Click to load diff…

added android/app/src/main/java/org/neiam/fieri/app/ui/RestaurantScreen.kt
+153 −0

Click to load diff…

added android/app/src/main/java/org/neiam/fieri/app/ui/SignInScreen.kt
+221 −0

Click to load diff…

added android/app/src/main/java/org/neiam/fieri/app/ui/theme/AppTheme.kt
+209 −0

Click to load diff…

added android/app/src/main/java/org/neiam/fieri/app/ui/theme/FieriTheme.kt
+21 −0

Click to load diff…

added android/app/src/main/java/org/neiam/fieri/app/ui/theme/Typography.kt
+38 −0

Click to load diff…

added android/app/src/main/res/drawable/ic_icon_foreground.xml
+18 −0

Click to load diff…

modified ?
+0 −0

Click to load diff…

modified ?
+0 −0

Click to load diff…

added android/app/src/main/res/mipmap-anydpi-v26/ic_icon.xml
+6 −0

Click to load diff…

added android/app/src/main/res/values/ic_icon.xml
+6 −0

Click to load diff…

added android/app/src/main/res/values/strings.xml
+4 −0

Click to load diff…

added android/app/src/main/res/values/themes.xml
+5 −0

Click to load diff…

added android/app/src/main/res/xml/data_extraction_rules.xml
+10 −0

Click to load diff…

added android/app/src/test/java/org/neiam/fieri/app/data/ApiDecodingTest.kt
+99 −0

Click to load diff…

added android/app/src/test/resources/api/area.json
+1 −0

Click to load diff…

added android/app/src/test/resources/api/areas.json
+1 −0

Click to load diff…

added android/app/src/test/resources/api/login.json
+1 −0

Click to load diff…

added android/app/src/test/resources/api/outing_decided.json
+1 −0

Click to load diff…

added android/app/src/test/resources/api/outing_open.json
+1 −0

Click to load diff…

added android/app/src/test/resources/api/outing_voted.json
+1 −0

Click to load diff…

added android/app/src/test/resources/api/people.json
+1 −0

Click to load diff…

added android/app/src/test/resources/api/profile.json
+1 −0

Click to load diff…

added android/app/src/test/resources/api/rank.json
+1 −0

Click to load diff…

added android/app/src/test/resources/api/restaurant.json
+1 −0

Click to load diff…

added android/build.gradle.kts
+7 −0

Click to load diff…

added android/gradle.properties
+4 −0

Click to load diff…

added android/gradle/libs.versions.toml
+50 −0

Click to load diff…

modified ?
+0 −0

Click to load diff…

added android/gradle/wrapper/gradle-wrapper.properties
+7 −0

Click to load diff…

added android/gradlew
+251 −0

Click to load diff…

added android/gradlew.bat
+94 −0

Click to load diff…

added android/settings.gradle.kts
+25 −0

Click to load diff…

Parents: 73e11cd