Stickers in FlameNet's art, cards, events and answers, games in one-to-one chats, the Today tab, the small-web directory and hello links. versionCode and versionName move together, as the comment above them requires. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
304 lines
16 KiB
Kotlin
304 lines
16 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 TWO NUMBERS ARE THE SAME NUMBER NOW, and that is the whole point
|
|
// of this release. They used to differ by one: 0.11 was built and never
|
|
// published, the build after it went out as code 12 before the name was
|
|
// settled, and the code can never come back down. The name spent eleven
|
|
// releases one behind, and somebody comparing the download page against
|
|
// their own app had to know that one number is ordering and the other
|
|
// is a label. Code 24 with name 0.24 closes it by skipping the name
|
|
// 0.23, which costs nothing because a name is a label and may skip.
|
|
// From here they move together and match: 25 with 0.25, 26 with 0.26.
|
|
versionCode = 32
|
|
versionName = "0.32"
|
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
|
}
|
|
|
|
sourceSets {
|
|
getByName("main") { java.srcDir("src/main/kotlin") }
|
|
getByName("androidTest") { java.srcDir("src/androidTest/kotlin") }
|
|
// Code that differs between the two builds. The Today dev key is
|
|
// compiled into debug builds and is absent from release.
|
|
getByName("debug") { java.srcDir("src/debug/kotlin") }
|
|
getByName("release") { java.srcDir("src/release/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.
|
|
|
|
// Which unit tests take an Activity rule, read off the sources. See the
|
|
// exclusion below for why they cannot run on the release variant.
|
|
val activityBackedTests =
|
|
fileTree("src/test/kotlin") { include("**/*.kt") }
|
|
.filter { it.readText().contains("createAndroidComposeRule") }
|
|
.map { "**/${it.name.removeSuffix(".kt")}*" }
|
|
.toSet()
|
|
|
|
testOptions {
|
|
unitTests {
|
|
isIncludeAndroidResources = true
|
|
isReturnDefaultValues = true
|
|
all {
|
|
it.systemProperty("robolectric.graphicsMode", "NATIVE")
|
|
|
|
// Tests that launch an Activity 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 every one of them dies 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.
|
|
//
|
|
// The names are read off the sources rather than typed here.
|
|
// They were typed here, and the list said `ScreenshotTest`
|
|
// only: the next test to take an Activity rule was written
|
|
// months later, nobody thought about a list in the build
|
|
// file, and it failed four times on every release run from
|
|
// the day it landed. A list that has to be remembered is a
|
|
// list that goes stale.
|
|
if (it.name.contains("Release")) {
|
|
it.exclude(activityBackedTests)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
dependencies {
|
|
implementation(project(":engine"))
|
|
implementation(project(":client"))
|
|
// The rules of the games played in chats (§6.33). Plain JVM, no Android.
|
|
implementation(project(":games"))
|
|
// 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")
|
|
}
|