import java.io.File import java.util.Properties // The Android app shell. Everything below it โ€” the cryptography and the wire // layer โ€” lives in :engine and :client, which are plain JVM libraries so they // can be tested without any of this. plugins { id("com.android.application") kotlin("android") id("org.jetbrains.kotlin.plugin.compose") } android { namespace = "io.flamenet.messenger" compileSdk = 35 defaultConfig { applicationId = "io.flamenet.messenger" // Android exposes the XDH algorithm from API 33. Below that a provider // such as BouncyCastle has to be registered at startup; the engine is // provider-agnostic, but nothing here does that registration yet, so // claiming a lower floor would be claiming something untrue. minSdk = 33 targetSdk = 35 // ๐Ÿ”ด **Bump BOTH on every published build, and never reuse a code.** // Nothing installs this app but the person holding the phone, so // `versionCode` is not bookkeeping โ€” it is the only thing that tells an // existing install a newer build exists. `UpdateCheck` compares this // against what the relay publishes in `downloads/android-latest.json`, // and `scripts/release-manifest.sh` reads it back out of the built APK // to write that file, so the two cannot drift. // // โš ๏ธ Android also refuses to install an APK whose `versionCode` is // lower than the installed one, so a code that goes backwards makes a // release uninstallable for everybody already on the higher number. versionCode = 10 versionName = "0.10" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } sourceSets { getByName("main") { java.srcDir("src/main/kotlin") } getByName("androidTest") { java.srcDir("src/androidTest/kotlin") } } buildFeatures { compose = true } compileOptions { sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17 } kotlin { compilerOptions { jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17) } } // Read from a properties file outside the repository, so the keystore and // its password are never in git and a checkout without them still builds // (unsigned, which is exactly what an unsigned build should be). // ๐Ÿ”ด **This app's OWN key, not the one every Flamenet Android app shares.** // `.flamenet-android-signing.properties` was made for Flamenet Files, and // because the path is app-agnostic this app silently inherited it โ€” every // release before 2026-08-25 went out as `CN=Flamenet Files`. Nobody chose // that for a messenger; it fell out of the layout. // // It matters because **an Android signing key is permanent**: once a device // installs a build signed with one key, it REFUSES every later build signed // with another. Sharing one key across products also means one compromised // keystore takes out both. // // So: prefer this app's own file, fall back to the shared one so a machine // that only has the old setup still produces a signed build rather than // failing confusingly. // // โš ๏ธ Both files live outside the repo and hold a password in the clear. They // are never in git, and a checkout without them still builds โ€” unsigned, // which is exactly what an unsigned build should be. val signingProperties = Properties() val home = File(System.getProperty("user.home")) val signingFile = listOf( File(home, ".flamenet-messenger-signing.properties"), File(home, ".flamenet-android-signing.properties"), ).firstOrNull { it.exists() } signingFile?.inputStream()?.use { signingProperties.load(it) } signingConfigs { if (signingProperties.isNotEmpty()) { create("release") { storeFile = File(signingProperties.getProperty("storeFile")) storePassword = signingProperties.getProperty("storePassword") keyAlias = signingProperties.getProperty("keyAlias") keyPassword = signingProperties.getProperty("keyPassword") } } } buildTypes { release { // โš ๏ธ **WebRTC ships native libraries for four ABIs and they // dominate the APK** โ€” about 41 MB uncompressed, of which x86 and // x86_64 are ~24 MB and exist only for emulators. Real phones are // arm64-v8a, with armeabi-v7a for old ones. // // Restricted here rather than split into per-ABI APKs on purpose: // this app is handed to testers as one file, and per-ABI outputs // turn "send them the APK" into "send them the right APK". // // Debug keeps all four, because that is what runs on an emulator. ndk { abiFilters += setOf("arm64-v8a", "armeabi-v7a") } isMinifyEnabled = true // ๐Ÿ”ด **R8 accepts a missing rules file in silence.** This one was // referenced here and did not exist, so the release build shipped // with the platform defaults alone โ€” and answering a call aborted // the process, because WebRTC's native JNI_OnLoad resolves its Java // classes by name and R8 had renamed them. Nothing failed, nothing // warned, and no debug test could see it. Fail the build instead. file("proguard-rules.pro").takeIf { it.isFile } ?: throw GradleException( "app/proguard-rules.pro is missing. The release build needs it โ€” " + "without the org.webrtc keep rules, answering a call aborts the " + "process inside JNI_OnLoad with no Kotlin stack trace.") proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") signingConfig = signingConfigs.findByName("release") } } packaging { resources.excludes += setOf("META-INF/*.kotlin_module", "META-INF/DEPENDENCIES") } // ๐Ÿ”ด **So screens can be rendered without an emulator.** Robolectric in // native-graphics mode draws real Compose pixels on the JVM, which is what // makes `./gradlew :app:test` able to produce a PNG of a screen. The // emulator died four times in one afternoon under memory pressure, and an // 8 GB AVD to look at a layout is a poor trade. // // โš ๏ธ `isIncludeAndroidResources` is not optional here โ€” without it the // theme cannot resolve and every render throws on a missing resource, // which reads as a Compose problem rather than a configuration one. testOptions { unitTests { isIncludeAndroidResources = true isReturnDefaultValues = true all { it.systemProperty("robolectric.graphicsMode", "NATIVE") } } } } dependencies { implementation(project(":engine")) implementation(project(":client")) implementation(platform("androidx.compose:compose-bom:2024.12.01")) implementation("androidx.compose.material3:material3") implementation("androidx.compose.material:material-icons-extended") implementation("androidx.compose.ui:ui") implementation("androidx.compose.ui:ui-tooling-preview") debugImplementation("androidx.compose.ui:ui-tooling") implementation("androidx.activity:activity-compose:1.9.3") // The app lock. `BiometricPrompt` covers fingerprint, face AND the device // credential, so a phone with a PIN and no biometric hardware can still use // it โ€” matching iOS, which asks for `.deviceOwnerAuthentication` rather // than biometrics specifically for the same reason. implementation("androidx.biometric:biometric:1.1.0") // ๐Ÿ”ด A version FLOOR, not a feature bump. biometric:1.1.0 drags in a // 2019-era androidx.fragment whose FragmentActivity still enforces the // 16-bit requestCode check โ€” and Compose's ActivityResultRegistry mints // codes far above that, so tapping ANY picker (post a story, change the // avatar) threw "Can only use lower 16 bits for requestCode" and closed // the app. Fragment 1.3+ removed the check; pin a current one explicitly. implementation("androidx.fragment:fragment-ktx:1.8.5") implementation("androidx.core:core-ktx:1.15.0") implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7") implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.7") implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0") // Voice calls (ยง8.10). // // The one third-party dependency in this app, and it is here because the // alternative is implementing ICE, DTLS-SRTP and an audio pipeline, which // is not a thing to hand-roll. `webrtc-sdk` is the maintained build of // Google's own libwebrtc โ€” the upstream project stopped publishing usable // Android artifacts, and every Android client of consequence uses either // this or its own build of the same source. // // โš ๏ธ It is a large native blob: roughly 15-20 MB of the APK, and not // something that can be audited line by line. Worth stating rather than // absorbing quietly, because everything else in this app deliberately is // auditable. implementation("io.github.webrtc-sdk:android:125.6422.07") // Reads the QR on somebody's Address screen. Decode only, pure Java, no // services โ€” the camera frames come from the WebRTC capture stack above, // so this is the algorithm (finder patterns + Reed-Solomon), not a second // camera framework. The encoder half stays our own (`engine`'s QrCode). implementation("com.google.zxing:core:3.5.3") // On-device tests. The JVM suites cannot see a provider difference, and a // provider difference is what broke X25519 the first time this engine's // sibling ran on a phone. // Plain JVM unit tests for the app module. There were none until a // StateFlow write-back bug in SiteConfig shipped unnoticed; logic that // lives in :app and needs no device belongs here, not in androidTest. testImplementation(kotlin("test")) // ๐Ÿ”ด The Android framework's `org.json` is STUBBED in unit tests โ€” every // method returns null or throws. A test that builds a JSONObject fails with // a NullPointerException that says nothing about the cause. The engine and // client modules already pull the real implementation in for exactly this; // the app module needs it for the same reason. testImplementation("org.json:json:20240303") testImplementation("junit:junit:4.13.2") // Screenshot tests that need no device. โš ๏ธ Test-only dependencies: none of // this reaches the APK. Robolectric is the standard JVM Android runtime and // is what AndroidX's own screenshot tooling is built on โ€” the alternative // to adopting it is not writing our own, it is keeping an 8 GB emulator // alive to look at a layout. testImplementation("org.robolectric:robolectric:4.14.1") // Drives Session against a real HTTP server rather than a hand-written // fake, the same way :client's tests do โ€” a fake agrees with the client // about every field name, including the ones it gets wrong. testImplementation("com.squareup.okhttp3:mockwebserver:4.12.0") testImplementation("androidx.test:core-ktx:1.6.1") testImplementation("androidx.test.ext:junit:1.2.1") testImplementation(platform("androidx.compose:compose-bom:2024.12.01")) testImplementation("androidx.compose.ui:ui-test-junit4") debugImplementation("androidx.compose.ui:ui-test-manifest") androidTestImplementation("androidx.test.ext:junit:1.2.1") androidTestImplementation("androidx.test:runner:1.6.2") // GrantPermissionRule: permissions have to be granted before the // activity launches, or its permission dialog steals focus from Compose. androidTestImplementation("androidx.test:rules:1.6.1") androidTestImplementation(platform("androidx.compose:compose-bom:2024.12.01")) androidTestImplementation("androidx.compose.ui:ui-test-junit4") debugImplementation("androidx.compose.ui:ui-test-manifest") androidTestImplementation("androidx.tracing:tracing:1.2.0") }