1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
import org.gradle.testing.jacoco.plugins.JacocoTaskExtension
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
import java.io.FileInputStream
import java.util.Properties
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.compose)
alias(libs.plugins.kotlin.serialization)
alias(libs.plugins.ksp)
alias(libs.plugins.hilt)
jacoco
}
// Firebase config for push, kept out of the repo like the upload key. Absent
// on a fresh clone and on CI, in which case the plugin is skipped and the app
// builds without push rather than failing — PushManager checks at runtime
// whether Firebase actually initialized and no-ops when it didn't.
//
// It lives under src/release because the debug variant's application id carries
// a `.debug` suffix, and the plugin fails outright on a config with no client
// for the id being built. Scoping it to release leaves debug with no config at
// all, which the WARN strategy below turns from an error into a line of build
// output — debug builds get no push, which is what they got before Firebase
// existed here.
val googleServicesFile = file("src/release/google-services.json")
if (googleServicesFile.exists()) {
apply(plugin = "com.google.gms.google-services")
extensions.configure<com.google.gms.googleservices.GoogleServicesPlugin.GoogleServicesPluginConfig>(
"googleServices",
) {
missingGoogleServicesStrategy =
com.google.gms.googleservices.GoogleServicesPlugin.MissingGoogleServicesStrategy.WARN
}
}
// Upload-key credentials, kept out of the repo. Absent on a fresh clone and on
// CI, in which case release builds are simply left unsigned rather than failing.
val keystorePropertiesFile = rootProject.file("keystore.properties")
val keystoreProperties = Properties().apply {
if (keystorePropertiesFile.exists()) {
FileInputStream(keystorePropertiesFile).use { load(it) }
}
}
// Whether this build is being made to measure coverage. See buildTypes.debug.
val coverageBuild = providers.gradleProperty("coverage").isPresent
android {
namespace = "social.grain"
compileSdk = 37
defaultConfig {
applicationId = "social.grain"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
minSdk = 26
targetSdk = 36
// Must increase on every upload, including builds that never ship.
versionCode = 15
versionName = "0.8.0"
vectorDrawables.useSupportLibrary = true
// The grain-serve a build talks to. The client ID, the OAuth redirect
// scheme (the host reversed) and the proxy target all follow it, so
// `oauthScheme`, which the manifest registers, has to match it.
buildConfigField("String", "SERVER_URL", "\"https://grain.social\"")
manifestPlaceholders["oauthScheme"] = "social.grain"
}
signingConfigs {
create("release") {
if (keystoreProperties.isNotEmpty()) {
storeFile = rootProject.file(keystoreProperties.getProperty("storeFile"))
storePassword = keystoreProperties.getProperty("storePassword")
keyAlias = keystoreProperties.getProperty("keyAlias")
keyPassword = keystoreProperties.getProperty("keyPassword")
}
}
}
buildTypes {
debug {
applicationIdSuffix = ".debug"
// A debug install sits beside a release one and registers the same
// `grain://oauth` callback, so the system's "open with" chooser
// lists both. Two entries reading "Grain" is a coin flip that sends
// a sign-in back to the wrong app; this is what tells them apart.
manifestPlaceholders["appLabel"] = "Grain debug"
// Coverage is measured on debug: it is the only variant the tests
// run against, and JaCoCo's instrumentation and R8 are a bad pair —
// release shrinks away the very code the report wants to name.
//
// Off unless `-Pcoverage` is passed, because
// `enableAndroidTestCoverage` instruments the *app* APK, not just
// the test one — leaving it on puts the JaCoCo runtime and a probe
// in every branch of the debug build that `just emulator` installs
// for the two-minute iteration loop. `just coverage` passes the
// flag. Toggling it recompiles, which is the price of not paying
// for coverage on every unrelated install.
//
// `enableUnitTestCoverage` is deliberately *not* set: see the
// JacocoTaskExtension block below for why the JVM half is measured
// with the agent instead.
enableAndroidTestCoverage = coverageBuild
}
// A debug build against next.grain.social, the grain-serve deploy
// beside production, installed beside the other two.
create("next") {
initWith(getByName("debug"))
applicationIdSuffix = ".next"
matchingFallbacks += listOf("debug")
manifestPlaceholders["appLabel"] = "Grain next"
manifestPlaceholders["oauthScheme"] = "social.grain.next"
buildConfigField("String", "SERVER_URL", "\"https://next.grain.social\"")
}
release {
manifestPlaceholders["appLabel"] = "@string/app_name"
// This is the *upload* key, not the app signing key. Google holds
// the latter under Play App Signing and re-signs each release, so
// losing this one is recoverable through Play support.
signingConfig = signingConfigs.getByName("release").takeIf {
keystoreProperties.isNotEmpty()
}
isMinifyEnabled = true
isShrinkResources = true
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
buildFeatures {
compose = true
buildConfig = true
}
packaging {
resources.excludes += "/META-INF/{AL2.0,LGPL2.1}"
}
testOptions {
unitTests {
// android.jar's stubs throw by default, so a unit test that only
// brushes past `android.util.Log` on an error path fails for that
// and nothing else. Returning defaults keeps those paths testable
// off a device; anything that needs a real Android behaviour still
// belongs in androidTest.
isReturnDefaultValues = true
// Robolectric reads the merged manifest and the resource table, so
// a unit test can inflate a theme and read a string the same way
// the app does.
isIncludeAndroidResources = true
}
}
// The runtime AGP packages into the instrumented APK. Pinned to the same
// version as the `jacoco` block below so the `.ec` files it writes are
// readable by the report task.
testCoverage {
jacocoVersion = libs.versions.jacoco.get()
}
}
jacoco {
toolVersion = libs.versions.jacoco.get()
}
/**
* Coverage for the JVM half, measured with the JaCoCo agent rather than AGP's
* `enableUnitTestCoverage`.
*
* That flag instruments the class files ahead of time, and the unit tests here
* run under Robolectric — which loads the app's classes in its own sandbox
* classloader, re-instrumenting them on the way in. The probes survive but
* report to a copy of the JaCoCo runtime living inside the sandbox, which
* nothing ever dumps: every Robolectric test contributed exactly nothing to the
* report, silently, while the handful of plain-JVM tests still showed up.
*
* The agent transforms whatever the JVM defines, sandbox classloader included,
* and keeps its runtime in the system loader where the dump can find it.
*/
tasks.withType<Test>().configureEach {
extensions.configure(JacocoTaskExtension::class.java) {
isEnabled = coverageBuild
// Robolectric's loader defines classes with no source location, which
// the agent skips by default — and skipping them is the same silent
// empty report by another route.
isIncludeNoLocationClasses = true
excludes = listOf("jdk.internal.*")
}
}
// AGP 9 compiles Kotlin itself, so the JVM target moves out of the removed
// android.kotlinOptions block and onto the Kotlin extension.
kotlin {
compilerOptions {
jvmTarget.set(JvmTarget.JVM_17)
}
}
dependencies {
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.lifecycle.viewmodel.compose)
implementation(libs.androidx.activity.compose)
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.ui)
implementation(libs.androidx.ui.graphics)
implementation(libs.androidx.ui.tooling.preview)
implementation(libs.androidx.material3)
// NavigationSuiteScaffold picks the bottom bar / nav rail / drawer for the
// window it finds itself in; `adaptive` supplies currentWindowAdaptiveInfo.
implementation(libs.androidx.material3.adaptive.navigation.suite)
implementation(libs.androidx.adaptive)
// The ~50 icon core set only. `material-icons-extended` is frozen at 1.7.8
// and carries thousands of vectors this app never draws; the two dozen it
// does use that aren't here are vendored in ui/icons + res/drawable.
implementation(libs.androidx.material.icons.core)
debugImplementation(libs.androidx.ui.tooling)
implementation(libs.androidx.navigation.compose)
implementation(libs.androidx.browser)
implementation(libs.androidx.exifinterface)
implementation(libs.androidx.camera.core)
implementation(libs.androidx.camera.camera2)
implementation(libs.androidx.camera.lifecycle)
implementation(libs.androidx.camera.compose)
implementation(libs.h3)
// MapLibre, drawing grain's own Protomaps basemap — the same tiles as
// grain.social, no API key and no Play services. See assets/basemap.
implementation(libs.maplibre.compose)
implementation(platform(libs.firebase.bom))
implementation(libs.firebase.messaging)
implementation(libs.hilt.android)
implementation(libs.hilt.navigation.compose)
ksp(libs.hilt.compiler)
implementation(libs.kotlinx.serialization.json)
implementation(libs.okhttp)
implementation(libs.okhttp.logging)
implementation(libs.coil.compose)
implementation(libs.coil.network.okhttp)
testImplementation(libs.junit)
testImplementation(libs.kotlinx.coroutines.test)
testImplementation(libs.robolectric)
testImplementation(libs.androidx.test.core)
testImplementation(libs.androidx.junit)
testImplementation(platform(libs.androidx.compose.bom))
testImplementation(libs.androidx.ui.test.junit4)
testImplementation(libs.androidx.ui.test.manifest)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.test.runner)
androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.androidx.ui.test.junit4)
debugImplementation(libs.androidx.ui.test.manifest)
}
// MARK: - Coverage
//
// Neither half of the test suite writes a report — only raw execution data, of
// two different kinds in two different places: an `.exec` the JaCoCo agent
// dumps from the JVM tests, and an `.ec` per connected device from the
// instrumented run. This task is what merges them.
//
// Both are optional inputs, deliberately. `testDebugUnitTest` alone produces an
// honest report — the screens run under Robolectric, so the JVM half covers
// nearly everything — and a clean tree with neither file gives a 0% report
// rather than a build failure.
val coverageExclusions = listOf(
// Android's own generated resource and manifest plumbing.
"**/R.class", "**/R$*.class", "**/BuildConfig.*", "**/Manifest*.*",
// Hilt/Dagger. KSP emits a component, a module wrapper and a factory per
// injection site; counting them scores the annotation processor, not us.
"**/*_Factory*.*", "**/*_MembersInjector*.*", "**/*_HiltModules*.*",
"**/Dagger*.*", "**/Hilt_*.*", "**/*_Hilt*.*", "**/hilt_aggregated_deps/**",
"**/*_GeneratedInjector*.*", "**/*_ComponentTreeDeps*.*",
// The aggregating sentinels Hilt emits into its own package rather than
// ours; they carry no name of ours to match the patterns above.
"dagger/hilt/internal/**",
// kotlinx.serialization's generated `$serializer` objects — machine-written
// encode/decode loops whose branches no test will ever walk exhaustively.
"**/*\$\$serializer.*",
// Compose lifts every non-capturing lambda in a file into one synthetic
// ComposableSingletons class. It is generated, and it is enormous.
"**/ComposableSingletons*.*",
// @Preview functions exist to be looked at in Studio, not run.
"**/Previews*.*",
)
// AGP 9 compiles Kotlin itself rather than delegating to a separate
// `compileDebugKotlin`, so the classes land under `intermediates`, not the
// `tmp/kotlin-classes/debug` that every pre-9 JaCoCo recipe on the internet
// points at. Both are listed: the stale path simply contributes nothing on
// AGP 9, and keeps this working if a future version moves back.
val coverageClassDirs = listOf(
"tmp/kotlin-classes/debug",
"intermediates/classes/debug/transformDebugClassesWithAsm/dirs",
"intermediates/javac/debug/classes",
)
/**
* What both coverage tasks measure, applied to each of them.
*
* Shared so the gate and the report can never disagree: a rule that fails on a
* number the report doesn't show is a rule nobody can act on.
*
* The ordering is the subtle half. Neither task *depends* on a test task —
* both read whatever execution data is on disk, which is what lets one run
* grade the JVM half and another the merged pair. But when a test task is in
* the same invocation, which is the normal way to run these, it has to go
* first: without this the report came out grading the previous run, and Gradle
* failed the build outright once the gate was added beside it.
*/
fun JacocoReportBase.useGrainCoverageData() {
// The classes being measured have to be compiled before anything measures
// them. `matching` rather than a name, so a build that never configures the
// task isn't broken by referring to it.
dependsOn(tasks.matching { it.name == "transformDebugClassesWithAsm" })
mustRunAfter(
tasks.matching {
it.name == "testDebugUnitTest" || it.name == "connectedDebugAndroidTest"
},
)
classDirectories.setFrom(
files(coverageClassDirs.map { dir ->
fileTree(layout.buildDirectory.dir(dir)) { exclude(coverageExclusions) }
}),
)
sourceDirectories.setFrom(files("src/main/java"))
// Globbed rather than named. The instrumented run writes its `.ec` under a
// directory named for the device it ran on, so the path is not knowable
// until a device exists.
executionData.setFrom(
fileTree(layout.buildDirectory) {
include(
// The agent's own output, for the JVM half.
"jacoco/*.exec",
// Where AGP files it when `enableUnitTestCoverage` is on, kept
// so a report still merges cleanly if that is ever turned back on.
"outputs/unit_test_code_coverage/**/*.exec",
"outputs/code_coverage/**/*.ec",
)
},
)
}
tasks.register<JacocoReport>("coverageReport") {
group = "verification"
description = "Merges unit-test and instrumented coverage into one HTML/XML report."
useGrainCoverageData()
reports {
html.required = true
xml.required = true
csv.required = false
}
doLast {
val report = reports.html.outputLocation.get().asFile.resolve("index.html")
logger.lifecycle("Coverage report: file://$report")
}
}
/**
* The gate. Fails the build when line coverage drops below the bar.
*
* Line rather than branch: branch coverage sits around 60%, and most of what it
* counts in a Compose codebase is the null checks and default-argument masks
* the compiler writes, not decisions anyone made. Gating on that would be
* gating on the compiler.
*/
tasks.register<JacocoCoverageVerification>("coverageVerification") {
group = "verification"
description = "Fails when line coverage falls below the agreed floor."
useGrainCoverageData()
// JacocoCoverageVerification ships an `onlyIf` that skips the whole task
// when it finds no execution data, which turns "the tests never ran" into a
// passing gate — the one failure a gate exists to catch. Replaced with a
// check that says so instead.
setOnlyIf("always, so a missing run fails rather than skips") { true }
doFirst {
check(!executionData.isEmpty) {
"No coverage execution data under ${layout.buildDirectory.get()}. " +
"Run the tests with -Pcoverage first — a gate that passes " +
"because nothing ran is worse than no gate at all."
}
}
violationRules {
rule {
limit {
counter = "LINE"
value = "COVEREDRATIO"
// The JVM half alone measures 81%. The bar is the target rather
// than the high-water mark: a point of slack absorbs a screen
// landing before its test without turning the gate into
// something people learn to re-run.
minimum = "0.80".toBigDecimal()
}
}
}
}