neiam
/diogramos
diogramos
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
Log in to mark this repository.
Android client phase 1b: scaffold and both auth paths
b9a6d14 · Gabriel Morell · 2026-09-14 22:17
Message
{commit_body(@commit)}
Files changed
modified
.gitignore
+9
−0
| 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
| 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
| 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
| 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
| 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