Two gaps, and the second was the bigger one. obfs4 was reachable from nothing. The socket-factory seam landed in !77, but every client in the app takes `defaultHttpClient()`'s null default — `RelayClient`, `MessengerApi`, `AccountsClient`, `RelayAccounts`, `SignupProofOfWork`, `RelayDiscovery` and the background service, ten sites in all, several from default parameters evaluated at field-initialisation time. The transport had been built, tested and proven against real lyrebird in both directions, and no user could switch it on. Threading a bridge through ten constructors would have worked until an eleventh was added, and the eleventh would connect directly, successfully, with nothing to show that it had. A missed site does not fail; it silently does not protect. So the factory is installed unconditionally and consults `Obfs4Routing` per socket. With no bridge it delegates to the platform's own factory, which is what OkHttp would have used anyway — nothing changes for the overwhelming majority who never set one. Pooled connections are the trap underneath that. OkHttp keeps a connection alive across requests, so one opened through the old bridge serves requests for minutes after the user switches — or after they turn a bridge OFF, which is the dangerous direction. The pool is shared so `Obfs4Routing.use` can empty it. The other gap is the one flagged when the wiring merged: a malformed line parses to nothing, nothing means no bridge, and no bridge is a direct connection. So `BridgeLine.parse` reports which part is wrong, and `Obfs4Settings` stores only what it accepted. Java's decoder made that worse here than on iOS. `fromCert` appended `==`, which THROWS for most truncation lengths — 20, 29, 30, 50 and 51 bytes among them — so a short paste came back as unreadable gibberish while iOS, whose decoder is padding-lenient, called the same paste "cut short". That is the likeliest real mistake by a distance: bridge lines wrap in every chat app and terminal, and copying only the first line is ordinary. Decoding the bare string works at every length and lets the size check give the honest answer. Both forms produce identical bytes for a real 52-byte cert, so nothing that worked before changes. The messages are the same sentences iOS shows, pinned by a test. The two apps are handed the same lines by the same operators, and one support answer should cover both. None of them says obfs4, base64 or node id — whoever reaches this screen was handed a line and told to paste it, and an error in the protocol's vocabulary reads as "you are not qualified to fix this". `iat-mode` 1 and 2 are a caveat, not a rejection: they ask the client to space its writes and the bridge does not enforce them, so such a line connects and works. Refusing it would take a working bridge away from somebody who has no other one. The status line reads a handshake counter, never the stored preference. A saved bridge that is not there leaves the app connecting directly, and only a completed handshake can tell you otherwise — so a bridge that has carried nothing says exactly that, and the words "protected", "secure", "hidden" and "safe" are asserted absent from it. Live interop re-run against real lyrebird after the factory change: both directions still pass. 448 unit tests green, 145 in the app, and reverting the unconditional factory reddens the routing test. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
267 lines
14 KiB
Kotlin
267 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.
|
|
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")
|
|
|
|
// 🔴 **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 genuinely 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"))
|
|
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")
|
|
}
|