Files
agendula/app/build.gradle.kts
T
makiolaj ec50e0998c sync(chunk 2c): Keystore credentials, AccountManager, stub sync adapter
The platform half of chunk 2; the account-add UI is 2d, since it is a design
task and the piece that needs an on-device review.

A stub ContentProvider turned out to be required and was not in the plan: a
sync adapter registers against a content authority, and we publish no provider
since :provider was deleted. Without one there is nothing for contentAuthority
to name, nothing for requestSync to address, and hasAuthorityAccess() makes
every ContentResolver sync call a silent no-op at targetSdk 34+.

- CredentialStore: Keystore AES/GCM, blob in its own DataStore file.
  security-crypto is formally deprecated and terminal. Decryption failure means
  re-authenticate, never a crash — including ProviderException, which is a
  RuntimeException and escapes the obvious catches.
- CalDavAccounts + SyncAuthenticator: no password reaches AccountManager, which
  stores them as plain TEXT. The authenticator never returns null — a null is
  the protocol for "answering asynchronously", and nothing here does, so
  Settings would wait forever. addAccount refuses with a readable message until
  2d ships the screen, rather than opening the home screen and hanging.
- SyncAdapterService: enqueue and wait on the unique work *name*, not the
  request id — enqueueUniqueWork is async so the id is unknown when the wait
  starts, and under KEEP it may never exist at all. Being deduplicated is not
  a failure.
- Account type and authority are per build variant, so debug and release do
  not fight over ownership. SyncContractTest guards the Kotlin/resValue pair.
- The credential blob is the only thing excluded from backup: Keystore keys are
  non-exportable, so a restored ciphertext can never be decrypted.

Known trade-off recorded in network_security_config.xml and SYNC-PLAN.md: the
user CA store is trusted for all traffic, which chunk 5's cert4android should
replace rather than sit beside.

The instrumented tests here compile but have not been run — device work waits
for an explicit go-ahead.
2026-09-04 18:00:07 +02:00

256 lines
11 KiB
Kotlin

import java.util.Properties
import java.io.FileInputStream
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.compose)
alias(libs.plugins.ksp)
alias(libs.plugins.hilt)
}
val keystorePropertiesFile = rootProject.file("key.properties")
val keystoreProperties = Properties().apply {
if (keystorePropertiesFile.exists()) {
load(FileInputStream(keystorePropertiesFile))
}
}
android {
namespace = "de.jeanlucmakiola.agendula"
compileSdk = 37
defaultConfig {
applicationId = "de.jeanlucmakiola.agendula"
minSdk = 29
targetSdk = 36
// These committed values ARE the source of truth for a release: merging
// a bumped versionName into main triggers .gitea/workflows/release.yaml,
// which builds this version and then creates the matching vX.Y.Z tag +
// release itself (versionCode is pinned to MAJOR*10000 + MINOR*100 +
// PATCH from versionName, e.g. 0.2.0 -> 200). The Gitea release is marked
// as a pre-release while MAJOR is 0. See docs/RELEASING.md.
versionCode = 400
versionName = "0.4.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
// The sync-adapter and authenticator XML descriptors cannot read
// BuildConfig, so the two identifiers they need are generated here.
// Derived from applicationId so the debug and releaseTest builds get
// their own and can be installed alongside the real app without their
// accounts colliding. Must stay in step with SyncContract.
resValue("string", "account_type", "de.jeanlucmakiola.agendula.caldav")
resValue("string", "sync_authority", "de.jeanlucmakiola.agendula.sync")
}
signingConfigs {
if (keystorePropertiesFile.exists()) {
create("release") {
keyAlias = keystoreProperties["keyAlias"] as String
keyPassword = keystoreProperties["keyPassword"] as String
storeFile = file(keystoreProperties["storeFile"] as String)
storePassword = keystoreProperties["storePassword"] as String
}
}
}
buildTypes {
release {
// Keep release builds reproducible for F-Droid: don't let AGP embed
// build-environment git metadata (META-INF/version-control-info.textproto),
// whose `revision`/path content varies by build machine and is the only
// thing that otherwise differs from a clean from-source rebuild.
vcsInfo { include = false }
isMinifyEnabled = true
isShrinkResources = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
if (keystorePropertiesFile.exists()) {
signingConfig = signingConfigs.getByName("release")
}
}
debug {
applicationIdSuffix = ".debug"
isMinifyEnabled = false
resValue("string", "account_type", "de.jeanlucmakiola.agendula.debug.caldav")
resValue("string", "sync_authority", "de.jeanlucmakiola.agendula.debug.sync")
}
// A locally-installable twin of `release`: same R8 shrinking + obfuscation
// and resource shrinking, but debug-signed and given its own applicationId
// suffix so it installs alongside both the production app (signed with the
// real key) and the debug build. Used to smoke-test a release candidate on
// a real device before merging to main — R8-only breakage and first-run/
// permission states don't surface in the unminified debug build, nor on a
// device that already holds the permission. Never published. See
// docs/RELEASING.md.
create("releaseTest") {
initWith(getByName("release"))
applicationIdSuffix = ".releasetest"
signingConfig = signingConfigs.getByName("debug")
isMinifyEnabled = true
isShrinkResources = true
matchingFallbacks += "release"
resValue("string", "account_type", "de.jeanlucmakiola.agendula.releasetest.caldav")
resValue("string", "sync_authority", "de.jeanlucmakiola.agendula.releasetest.sync")
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
buildFeatures {
compose = true
buildConfig = true
// The account type and sync authority are generated per variant so the
// debug and releaseTest builds do not fight the real app over ownership
// of an account type. AGP 9 requires opting in.
resValues = true
}
// Don't embed AGP's dependency-metadata block in the APK signing block. It's
// a Play-oriented blob, and F-Droid's reproducible-build scanner rejects any
// "extra signing block" — so leaving it in blocks publishing to the official
// repo. It lives in the signing block, not the zip entries, so disabling it
// doesn't change the build output (reproducibility is unaffected).
dependenciesInfo {
includeInApk = false
includeInBundle = false
}
packaging {
resources {
excludes += "/META-INF/{AL2.0,LGPL2.1}"
}
}
lint {
// Community translations are expected to be partial — a missing string
// falls back to the English base at runtime — so don't fail the build on
// it. Likewise a translated <plurals> may not fill every CLDR quantity
// form its locale defines (e.g. Arabic needs "zero"); the missing form
// falls back to "other" at runtime, so MissingQuantity is informational
// too. Stale/extra keys (ExtraTranslation) stay fatal; scripts/
// check_translations.py guards the same invariants with clearer,
// translator-facing messages.
informational += listOf("MissingTranslation", "MissingQuantity")
}
testOptions {
unitTests {
all { it.useJUnitPlatform() }
isReturnDefaultValues = true
}
}
// MigrationTestHelper reads the exported schemas out of the test APK's
// assets, so app/schemas/ has to ship with the instrumented tests.
sourceSets.getByName("androidTest").assets.srcDir("$projectDir/schemas")
}
kotlin {
compilerOptions {
jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17
}
}
// Export each Room schema version to app/schemas/ and commit it. That JSON is
// what MigrationTestHelper reads to build an old database and migrate it, so
// without it a migration can only be tested by hand.
ksp {
arg("room.schemaLocation", "$projectDir/schemas")
}
dependencies {
// Not a dependency we use directly — lifecycle already drags it in at 1.7.3.
// AGP's consistent resolution then pins androidTest to the app classpath, and
// room-testing's MigrationTestHelper needs 1.8+ to deserialize the exported
// schema; on 1.7.3 it dies with an AbstractMethodError. Raise it in one place.
constraints {
implementation(libs.kotlinx.serialization.json)
}
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.appcompat)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.lifecycle.runtime.compose)
implementation(libs.androidx.lifecycle.process)
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)
implementation(libs.androidx.compose.material.icons.core)
implementation(libs.androidx.compose.material.icons.extended)
implementation(libs.hilt.android)
implementation(libs.androidx.hilt.navigation.compose)
implementation(libs.androidx.navigation.compose)
ksp(libs.hilt.compiler)
// Sync runs in WorkManager, triggered *through* the sync-adapter framework.
// hilt-work supplies the HiltWorkerFactory; its compiler generates the
// @HiltWorker plumbing.
implementation(libs.androidx.work.runtime.ktx)
implementation(libs.androidx.hilt.work)
ksp(libs.androidx.hilt.compiler)
// RFC 5545 recurrence expansion, in-process. Pinned at 0.12.2 — 0.16.0
// removed RecurrenceSet. rfc5545-datetime comes with it and is part of its
// API surface, so it isn't declared separately.
implementation(libs.dmfs.lib.recur)
// Vendored dav4jvm — the CalDAV protocol layer. See dav/PROVENANCE.md.
implementation(project(":dav"))
// Discovery, auth and Nextcloud Login Flow v2.
implementation(project(":caldav"))
// :dav gets org.xmlpull.v1 from the Android framework at runtime and declares
// xpp3 compileOnly, which is not transitive. Unit tests run on a plain JVM
// with no framework, and android.jar's stub factory returns null under
// isReturnDefaultValues — so anything touching XmlUtils would NPE without a
// real implementation here.
testImplementation(libs.xpp3)
implementation(libs.androidx.room.runtime)
implementation(libs.androidx.room.ktx)
ksp(libs.androidx.room.compiler)
implementation(libs.androidx.datastore.preferences)
implementation(libs.androidx.documentfile)
implementation(libs.androidx.glance.appwidget)
implementation(libs.androidx.glance.material3)
implementation(libs.kotlinx.datetime)
implementation(libs.kotlinx.coroutines.core)
implementation("de.jeanlucmakiola.floret:core-time")
implementation("de.jeanlucmakiola.floret:core-reminders")
implementation("de.jeanlucmakiola.floret:core-locale")
implementation("de.jeanlucmakiola.floret:core-crash")
implementation("de.jeanlucmakiola.floret:identity")
implementation("de.jeanlucmakiola.floret:components")
debugImplementation(libs.androidx.ui.tooling)
debugImplementation(libs.androidx.ui.test.manifest)
testImplementation(libs.junit.jupiter.api)
testRuntimeOnly(libs.junit.jupiter.engine)
testRuntimeOnly(libs.junit.platform.launcher)
testImplementation(libs.truth)
testImplementation(libs.turbine)
testImplementation(libs.kotlinx.coroutines.test)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)
androidTestImplementation(libs.androidx.test.rules)
androidTestImplementation(libs.truth)
androidTestImplementation(libs.androidx.room.testing)
androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.androidx.ui.test.junit4)
}