Files
flamenet-messenger-android/app/build.gradle.kts
T
2026-09-08 20:21:39 -04:00

278 lines
14 KiB
Kotlin

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
// on 2026-09-07 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.
versionCode = 13
versionName = "0.11"
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")
// 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")
}