Files
flamenet-messenger-android/app/build.gradle.kts
T
Paul HittandClaude Opus 5 e5815d27e2 Android app lock, matching iOS
The last of the four privacy features Android was missing that iOS already had.
The sealed history file protects a phone that is off or locked; it does nothing
about the phone somebody just handed to a friend. iOS asked for Face ID; on
Android anyone holding an unlocked handset read every conversation.

Biometrics OR the device credential, deliberately - a phone with a PIN and no
fingerprint reader should still be able to use this, the same reason iOS asks
for deviceOwnerAuthentication rather than biometrics specifically. Offered only
when the device can actually authenticate: a switch that turns on and then
cannot show a prompt locks somebody out of their own messages with no way back.

Two traps worth naming. MainActivity had to become a FragmentActivity -
BiometricPrompt hosts a fragment, so on a plain ComponentActivity it silently
cannot show and the callback never fires, which looks like a broken lock rather
than a missing base class. And the gate wraps Root rather than drawing over it:
composed underneath, the message list still renders for a frame, and a frame is
all a screenshot or a shoulder needs.

The lock screen deliberately says nothing - no sender, no preview, no count. A
lock screen reporting "3 new messages from Dana" has given away the two facts
that usually matter most.

The setting states what it does NOT do. This stops someone who picks up your
unlocked phone; it adds no encryption, because the vault and ratchet keys are
already sealed by the keystore either way. Claiming more would be selling a
screen as if it were a key.

Android engine, client and app suites green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 06:21:03 -04:00

191 lines
9.1 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
versionCode = 1
versionName = "0.1"
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
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")
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")
// 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"))
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")
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")
}