Android client phase 1b: scaffold and both auth paths

b9a6d14 · Gabriel Morell · 2026-09-14 22:17

75 files +2590 -4
Message
{commit_body(@commit)}

Files changed

modified .gitignore
+9 −0
@@ -39,3 +39,12 @@ npm-debug.log
39 39 app-secrets.yml
40 40 *.secret.yml
41 41
42 +
43 +# Android release signing material. The APK signing key cannot be
44 +# rotated — Android checks its certificate on every update — so a commit
45 +# of one is not recoverable by deleting it later. Keep it in OpenBao KV
46 +# and in the CI secret, never here.
47 +# See android/README.md.
48 +*.jks
49 +*.keystore
50 +keystore.pass
added android/.gitignore
+12 −0
@@ -0,0 +1,12 @@
1 +*.iml
2 +.gradle/
3 +local.properties
4 +.idea/
5 +.DS_Store
6 +build/
7 +captures/
8 +.externalNativeBuild
9 +.cxx/
10 +*.apk
11 +*.aab
12 +keys/release.jks
added android/README.md
+130 −0
@@ -0,0 +1,130 @@
1 +# Diogramos Android client
2 +
3 +Native Android client for [Diogramos](../). Talks to the JSON API at
4 +`/api/v1/...` and the realtime channel at `/socket`. Sister app to
5 +`../../waxx/android` — same Compose shell, OKLCh palettes and B612-mono
6 +typography, so the two feel like one product family.
7 +
8 +The design doc and phase plan live in [docs/android.md](../docs/android.md).
9 +Shipped so far: **phase 1b** — the scaffold and both auth paths. The canvas
10 +list is a placeholder that proves the credentials work; rendering arrives
11 +in phase 2.
12 +
13 +## Stack
14 +
15 +| Layer | Choice |
16 +|---|---|
17 +| Language / UI | Kotlin 2.0 · Jetpack Compose · Material 3 |
18 +| HTTP | Retrofit · OkHttp · kotlinx.serialization |
19 +| Realtime | `com.github.dsrees:JavaPhoenixClient` (phase 3) |
20 +| Storage | EncryptedSharedPreferences (AES via the Android Keystore) |
21 +| Scanner | `com.journeyapps:zxing-android-embedded` (`ScanContract`) |
22 +| Build | Gradle 9.0 · AGP 8.5.2 · JDK 17 |
23 +| minSdk | 26 (Android 8) · targetSdk 34 |
24 +
25 +Single-activity, single-module, no DI framework — dependencies are
26 +constructed inline. Adding Hilt when the screen count justifies it is a
27 +fine follow-up.
28 +
29 +## Building
30 +
31 +```sh
32 +cd android
33 +./gradlew :app:assembleDebug # → app/build/outputs/apk/debug/app-debug.apk
34 +./gradlew :app:testDebugUnitTest # JVM unit tests, no emulator needed
35 +./gradlew :app:installDebug # onto a running emulator or device
36 +```
37 +
38 +The SDK location comes from `local.properties` (`sdk.dir=…`), which is
39 +not committed. The first build downloads Gradle and a few hundred MB of
40 +dependencies; later ones are seconds.
41 +
42 +**JDK:** build with 17–21. AGP 8.5 does not support JDK 25, and lint
43 +throws on it with only the version string as the error message, so a
44 +build on a modern default JDK fails for reasons that have nothing to do
45 +with this code:
46 +
47 +```sh
48 +JAVA_HOME=/path/to/jdk21 ./gradlew :app:assembleDebug
49 +```
50 +
51 +## Running against a local dev server
52 +
53 +1. Start Phoenix (`mix phx.server`); it binds `http://localhost:4000`.
54 +2. The emulator reaches the host as `http://10.0.2.2:4000`. A physical
55 + device on the same LAN uses the host's LAN IP.
56 +3. Pair with one of:
57 + - **QR** — sign in on the web, Settings → Connected devices →
58 + Generate, scan the code. The server picks the URL it encodes from
59 + `DIOGRAMOS_PUBLIC_URL` / `X-Forwarded-*` / `Host`, so for emulator
60 + testing start Phoenix with `DIOGRAMOS_PUBLIC_URL=http://10.0.2.2:4000`
61 + or the QR will point at `localhost`, which on the emulator is the
62 + emulator.
63 + - **Magic link** — enter the same URL and your email, then read the
64 + link out of the dev mailbox at `/dev/mailbox` and paste it into the
65 + second field.
66 +
67 +`TokenStore` upgrades `http://<real domain>` to `https://` on save and
68 +load; IPs, `localhost` and unqualified hostnames are left alone, so the
69 +LAN and emulator paths keep working.
70 +
71 +## Auth
72 +
73 +Two paths, because they fail in different situations:
74 +
75 +- **QR pairing** needs a logged-in browser and physical access to its
76 + screen. No mail server, no App Links — which is why it leads on
77 + self-hosted installs.
78 +- **Magic link** is the answer for "I've only ever used the phone". It
79 + needs the server to send mail, and App Links for the seamless version.
80 +
81 +Either yields a bearer token, and the backend treats them identically
82 +afterwards. The token is verified with `GET /api/v1/users/me` *before*
83 +being stored: a scanned QR can name a server this phone cannot reach,
84 +and storing it unverified produces an app that fails later with no clue
85 +why.
86 +
87 +### App Links
88 +
89 +The `https://<host>/m/<token>` intent filter is `autoVerify="true"`,
90 +which needs `/.well-known/assetlinks.json` on the server declaring this
91 +app's SHA-256 signing fingerprints (debug and release). Without it the
92 +link still works — Android falls back to the default-handler chooser,
93 +and the paste box on the magic-link screen always works. The host comes
94 +from the `appLinksHost` placeholder in `app/build.gradle.kts`, currently
95 +`diogramos.example.com`.
96 +
97 +Read the fingerprints with:
98 +
99 +```sh
100 +keytool -list -v -keystore ~/.android/debug.keystore \
101 + -storepass android -alias androiddebugkey | grep SHA256
102 +keytool -list -v -keystore release.jks -alias diogramos | grep SHA256
103 +```
104 +
105 +## Themes
106 +
107 +`ui/theme/AppTheme.kt` carries all nine daisyUI themes from
108 +`assets/css/app.css`, token for token, with keys matching
109 +`Diogramos.Themes.all/0`.
110 +
111 +It holds every token in `Themes.color_tokens/0`, not just the ones
112 +Material needs, because a canvas stores colors as token *names* and
113 +resolves them against the canvas's own theme. That is what lets a diagram
114 +authored in `forest` render in `forest` colors while the app chrome stays
115 +in whatever the user picked — and it is why these values are copied from
116 +the CSS rather than eyeballed.
117 +
118 +## Tests
119 +
120 +`app/src/test/` holds plain JVM tests — no emulator, no Robolectric. That
121 +is why `PairPayload` parses with `java.net.URI` instead of
122 +`android.net.Uri`: the pairing parser is the one place where a mistake
123 +silently stores the wrong server, so it is worth being able to test it
124 +directly.
125 +
126 +## Not yet shipped
127 +
128 +Everything from phase 2 on: canvas rendering, the realtime channel,
129 +editing, export, the launcher icon, and the release/F-Droid pipeline.
130 +See [docs/android.md](../docs/android.md).
added android/app/build.gradle.kts
+111 −0
@@ -0,0 +1,111 @@
1 +plugins {
2 + alias(libs.plugins.android.application)
3 + alias(libs.plugins.kotlin.android)
4 + alias(libs.plugins.kotlin.compose)
5 + alias(libs.plugins.kotlin.serialization)
6 +}
7 +
8 +// Bumped by hand; everything after it is generated.
9 +val baseVersion = "0.1.0"
10 +
11 +// Seconds, from the environment when it's there. CI sets it once per run
12 +// so every artifact of that run agrees; a local build computes it, which
13 +// is fine because local builds are never published. SOURCE_DATE_EPOCH
14 +// first, because it is the cross-toolchain convention for exactly this.
15 +val buildTimestamp: Long =
16 + (System.getenv("SOURCE_DATE_EPOCH") ?: System.getenv("BUILD_TIMESTAMP"))
17 + ?.toLongOrNull()
18 + ?: (System.currentTimeMillis() / 1000)
19 +
20 +// A real release overrides the whole string; until there is one, every
21 +// build says so in its name rather than claiming to be 0.1.0.
22 +val appVersionName: String =
23 + System.getenv("RELEASE_VERSION")?.takeIf { it.isNotBlank() }
24 + ?: "$baseVersion-dev-$buildTimestamp"
25 +
26 +android {
27 + namespace = "org.neiam.diogramos.app"
28 + compileSdk = 34
29 +
30 + defaultConfig {
31 + applicationId = "org.neiam.diogramos.app"
32 + minSdk = 26
33 + targetSdk = 34
34 +
35 + // Android refuses an update whose versionCode is not higher than
36 + // what is installed, and F-Droid orders by it, so a hardcoded 1
37 + // would mean no phone ever sees a second release. The CI run
38 + // number is monotonic and needs no state kept anywhere. Local
39 + // builds stay at 1; they are never published.
40 + versionCode = (System.getenv("GITHUB_RUN_NUMBER") ?: "").toIntOrNull() ?: 1
41 + versionName = appVersionName
42 +
43 + // Host used for the magic-link App Link. Override once the
44 + // production domain is settled.
45 + manifestPlaceholders["appLinksHost"] = "diogramos.example.com"
46 + }
47 +
48 + buildTypes {
49 + release {
50 + isMinifyEnabled = false
51 + proguardFiles(
52 + getDefaultProguardFile("proguard-android-optimize.txt"),
53 + "proguard-rules.pro",
54 + )
55 + }
56 + }
57 +
58 + compileOptions {
59 + sourceCompatibility = JavaVersion.VERSION_17
60 + targetCompatibility = JavaVersion.VERSION_17
61 + }
62 + kotlinOptions {
63 + jvmTarget = "17"
64 + }
65 + buildFeatures {
66 + compose = true
67 + }
68 +
69 + lint {
70 + // assembleRelease is a packaging step here, not a review gate.
71 + // lintVital doubles the release build and fails for reasons that
72 + // have nothing to do with this code (it throws on JDK 25 with
73 + // only the version string as the error), so a release built on a
74 + // modern local JDK fails while the same commit builds fine in the
75 + // container's JDK. Lint still runs on demand: ./gradlew :app:lint
76 + checkReleaseBuilds = false
77 + }
78 + packaging {
79 + resources {
80 + excludes += "/META-INF/{AL2.0,LGPL2.1}"
81 + }
82 + }
83 +}
84 +
85 +dependencies {
86 + implementation(libs.androidx.core.ktx)
87 + implementation(libs.androidx.lifecycle.runtime)
88 + implementation(libs.androidx.lifecycle.viewmodel.compose)
89 + implementation(libs.androidx.activity.compose)
90 + implementation(libs.androidx.navigation.compose)
91 + implementation(libs.androidx.security.crypto)
92 +
93 + implementation(platform(libs.compose.bom))
94 + implementation(libs.compose.ui)
95 + implementation(libs.compose.ui.tooling.preview)
96 + implementation(libs.compose.material3)
97 + implementation(libs.compose.material.icons)
98 + debugImplementation(libs.compose.ui.tooling)
99 +
100 + implementation(libs.kotlinx.coroutines)
101 + implementation(libs.kotlinx.serialization.json)
102 + implementation(libs.retrofit)
103 + implementation(libs.retrofit.kotlinx.serialization)
104 + implementation(libs.okhttp)
105 + implementation(libs.okhttp.logging)
106 +
107 + implementation(libs.zxing.android.embedded)
108 + implementation(libs.phoenix.client)
109 +
110 + testImplementation(libs.junit)
111 +}
added android/app/proguard-rules.pro
+10 −0
@@ -0,0 +1,10 @@
1 +# Keep Retrofit / kotlinx.serialization metadata.
2 +-keepattributes *Annotation*, InnerClasses
3 +-dontnote kotlinx.serialization.AnnotationsKt
4 +-keep,includedescriptorclasses class org.neiam.waxx.app.**$$serializer { *; }
5 +-keepclassmembers class org.neiam.waxx.app.** {
6 + *** Companion;
7 +}
8 +-keepclasseswithmembers class org.neiam.waxx.app.** {
9 + kotlinx.serialization.KSerializer serializer(...);
10 +}
added android/app/src/main/AndroidManifest.xml
+55 −0

Click to load diff…

added android/app/src/main/java/org/neiam/diogramos/app/MainActivity.kt
+141 −0

Click to load diff…

added android/app/src/main/java/org/neiam/diogramos/app/auth/LoginScreen.kt
+75 −0

Click to load diff…

added android/app/src/main/java/org/neiam/diogramos/app/auth/MagicLinkScreen.kt
+210 −0

Click to load diff…

added android/app/src/main/java/org/neiam/diogramos/app/auth/PairScreen.kt
+192 −0

Click to load diff…

added android/app/src/main/java/org/neiam/diogramos/app/canvas/CanvasListScreen.kt
+118 −0

Click to load diff…

added android/app/src/main/java/org/neiam/diogramos/app/data/DiogramosClient.kt
+123 −0

Click to load diff…

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

Click to load diff…

added android/app/src/main/java/org/neiam/diogramos/app/data/PairPayload.kt
+74 −0

Click to load diff…

added android/app/src/main/java/org/neiam/diogramos/app/data/ThemeStore.kt
+25 −0

Click to load diff…

added android/app/src/main/java/org/neiam/diogramos/app/data/TokenStore.kt
+87 −0

Click to load diff…

added android/app/src/main/java/org/neiam/diogramos/app/ui/CommonText.kt
+18 −0

Click to load diff…

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

Click to load diff…

added android/app/src/main/java/org/neiam/diogramos/app/ui/theme/Theme.kt
+22 −0

Click to load diff…

added android/app/src/main/java/org/neiam/diogramos/app/ui/theme/Typography.kt
+38 −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/values/strings.xml
+4 −0

Click to load diff…

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

Click to load diff…

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

Click to load diff…

added android/app/src/test/java/org/neiam/diogramos/app/PairPayloadTest.kt
+85 −0

Click to load diff…

added android/app/src/test/java/org/neiam/diogramos/app/TokenStoreCoercionTest.kt
+39 −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
+51 −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…

modified docs/android.md
+22 −3

Click to load diff…

modified lib/diogramos_web/api/v1/session_controller.ex
+5 −1

Click to load diff…

added lib/diogramos_web/controllers/magic_link_controller.ex
+19 −0

Click to load diff…

modified lib/diogramos_web/router.ex
+3 −0

Click to load diff…

added test/diogramos_web/controllers/magic_link_controller_test.exs
+62 −0

Click to load diff…

Parents: 92c48b1