Every instrumented test died with "No compose hierarchies found in the
app". MainActivity.onCreate asks for POST_NOTIFICATIONS before it calls
setContent, and the permission dialog is a separate activity that takes
focus — so the Compose rule attached to a window with no hierarchy yet.
GrantPermissionRule at order = 0, outside the Compose rule at order = 1, so
the grant happens first and no dialog ever appears.
With this, the three fixes in the previous commit, and a relay on the host,
aPersonCanCreateAnAccountOnASelfHostedRelay passes: a real account created
on a real relay, driven by test tags. That is the first end-to-end pass this
harness has produced.
./gradlew :app:connectedDebugAndroidTest \
-Pandroid.testInstrumentationRunnerArguments.relay=10.0.2.2:18810
236 lines
12 KiB
Kotlin
236 lines
12 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.
|
|
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")
|
|
}
|