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. // The code is one ahead of the name, deliberately. 0.11 was built // and never published; the build that followed it went out // as versionCode 12 before the name was settled on. Going back to code // 11 would make the release uninstallable for anyone already on 12, see // the paragraph above, so the code keeps climbing and the NAME is what // closes the 0.10 -> 0.12 gap. They are allowed to differ: the code is // ordering, the name is what a person reads. // The NAME is what the website and the app show a person, and it now // matches the code rather than trailing it: somebody comparing the // download page against their own app should not have to know that one // number is ordering and the other is a label. // // The code goes to 22 because 21 is published and a code is never // reused. So they line up as closely as they can from here: name 0.21, // code 22, and each release from now moves both. versionCode = 22 versionName = "0.21" 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 that 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") // The screenshot tests are debug-variant tests, and // `./gradlew test` runs BOTH variants. // // They launch a `ComponentActivity`, which is declared by // `androidx.compose.ui:ui-test-manifest`, a `debugImplementation` // dependency, so the release variant's merged manifest has no // such activity and all fifteen die with "Unable to resolve // activity for Intent". // // The tempting fix is to add that dependency to release as // well. Do not: it exists to inject a test-only activity into // the manifest, and putting it on `releaseImplementation` would // ship that into the APK. The tests belong to the variant that // can run them. // // Excluded by name rather than by disabling // `testReleaseUnitTest` wholesale, everything else in the // suite still runs in both variants, so a genuine // release-variant difference would still be caught. if (it.name.contains("Release")) { it.exclude("**/ScreenshotTest*") } } } } } dependencies { implementation(project(":engine")) implementation(project(":client")) // A direct dependency rather than leaning on `:client` to re-export it. // The settings screen reads bridge lines and the handshake counter itself, // so the app uses these types; borrowing them through another // module's classpath would make `:client` unable to change its own // dependencies without breaking a screen that never mentioned it. implementation(project(":obfs4")) // BitmapFactory ignores EXIF orientation, so a photo from a phone // camera decodes on its side. This reads the tag so the rotation can be // applied before re-encoding drops it. See ImagePrep.orientedFrom. implementation("androidx.exifinterface:exifinterface:1.3.7") 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") }