diff --git a/app/build.gradle.kts b/app/build.gradle.kts index f7e37aa..9b5dad9 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -33,6 +33,14 @@ android { 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 { @@ -66,6 +74,8 @@ android { 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 @@ -82,6 +92,8 @@ android { isMinifyEnabled = true isShrinkResources = true matchingFallbacks += "release" + resValue("string", "account_type", "de.jeanlucmakiola.agendula.releasetest.caldav") + resValue("string", "sync_authority", "de.jeanlucmakiola.agendula.releasetest.sync") } } @@ -93,6 +105,10 @@ android { 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 @@ -177,6 +193,13 @@ dependencies { 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. @@ -184,6 +207,8 @@ dependencies { // 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 diff --git a/app/src/androidTest/java/de/jeanlucmakiola/agendula/data/sync/CredentialStoreTest.kt b/app/src/androidTest/java/de/jeanlucmakiola/agendula/data/sync/CredentialStoreTest.kt new file mode 100644 index 0000000..2750df2 --- /dev/null +++ b/app/src/androidTest/java/de/jeanlucmakiola/agendula/data/sync/CredentialStoreTest.kt @@ -0,0 +1,123 @@ +package de.jeanlucmakiola.agendula.data.sync + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStoreFile +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TestName +import org.junit.runner.RunWith + +/** + * The credential store, against a real Keystore. + * + * Instrumented rather than Robolectric because the thing under test *is* the + * platform: a shadowed Keystore would encrypt and decrypt happily and prove + * nothing about whether the key spec is usable for background sync. + * + * The case that matters most is the last one. A restored backup carries the + * ciphertext but not the key — Keystore keys are non-exportable — so the blob + * becomes permanently undecryptable. That must surface as "sign in again", never + * as a crash and never as a silently non-syncing account, which is why + * `backup_rules.xml` excludes this file in the first place. + */ +@RunWith(AndroidJUnit4::class) +class CredentialStoreTest { + + @get:Rule val testName = TestName() + + private lateinit var scope: CoroutineScope + private lateinit var dataStore: DataStore + private lateinit var store: CredentialStore + + private val context: Context get() = ApplicationProvider.getApplicationContext() + + @Before + fun setUp() { + // ⚠️ A file per test, and a scope we can cancel. DataStore's FileStorage + // keeps a process-wide set of active files and refuses a second + // connection to one ("There are multiple DataStores active for the same + // file"); the entry is released only when the owning scope's job + // completes, and the factory's default scope is never cancelled. Sharing + // one file across methods therefore fails every test after the first. + scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + dataStore = PreferenceDataStoreFactory.create(scope = scope) { + context.preferencesDataStoreFile("credential_store_test_${testName.methodName}") + } + store = CredentialStore(dataStore) + } + + @After + fun tearDown() { + runTest { store.clearAll() } + scope.cancel() + context.preferencesDataStoreFile("credential_store_test_${testName.methodName}").delete() + } + + @Test + fun anAppPasswordRoundTrips() = runTest { + assertThat(store.put(accountId = 1L, appPassword = "s3cret-app-pw")).isTrue() + assertThat(store.get(1L)).isEqualTo(CredentialStore.Secret.Present("s3cret-app-pw")) + } + + @Test + fun aNonLatin1PasswordSurvives() = runTest { + // The same charset trap the Basic interceptor has: anything that silently + // mangles "ä" produces a 401 the user reads as a wrong password. + store.put(accountId = 1L, appPassword = "pä§§wörd-🔐") + assertThat(store.get(1L)).isEqualTo(CredentialStore.Secret.Present("pä§§wörd-🔐")) + } + + @Test + fun accountsDoNotShareACredential() = runTest { + store.put(1L, "first") + store.put(2L, "second") + assertThat(store.get(1L)).isEqualTo(CredentialStore.Secret.Present("first")) + assertThat(store.get(2L)).isEqualTo(CredentialStore.Secret.Present("second")) + } + + @Test + fun anUnknownAccountIsAbsentRatherThanAnError() = runTest { + assertThat(store.get(99L)).isEqualTo(CredentialStore.Secret.Absent) + } + + @Test + fun clearingRemovesOnlyThatAccount() = runTest { + store.put(1L, "first") + store.put(2L, "second") + store.clear(1L) + assertThat(store.get(1L)).isEqualTo(CredentialStore.Secret.Absent) + assertThat(store.get(2L)).isEqualTo(CredentialStore.Secret.Present("second")) + } + + @Test + fun aCiphertextThisDeviceCannotDecryptMeansReAuthenticate() = runTest { + // Stands in for the restored-backup case: the blob is present and + // well-formed Base64, but was not produced by this device's key. + dataStore.edit { + it[stringPreferencesKey("caldav_app_password_1")] = + "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + } + assertThat(store.get(1L)).isInstanceOf(CredentialStore.Secret.Unrecoverable::class.java) + } + + @Test + fun aBlobThatIsNotBase64AtAllIsAlsoRecoverable() = runTest { + dataStore.edit { it[stringPreferencesKey("caldav_app_password_1")] = "not base64 !!" } + assertThat(store.get(1L)).isInstanceOf(CredentialStore.Secret.Unrecoverable::class.java) + } +} diff --git a/app/src/androidTest/java/de/jeanlucmakiola/agendula/data/sync/SyncContractTest.kt b/app/src/androidTest/java/de/jeanlucmakiola/agendula/data/sync/SyncContractTest.kt new file mode 100644 index 0000000..974ff1e --- /dev/null +++ b/app/src/androidTest/java/de/jeanlucmakiola/agendula/data/sync/SyncContractTest.kt @@ -0,0 +1,44 @@ +package de.jeanlucmakiola.agendula.data.sync + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.common.truth.Truth.assertThat +import de.jeanlucmakiola.agendula.R +import org.junit.Test +import org.junit.runner.RunWith + +/** + * The account type and authority exist in two places that cannot see each other: + * `SyncContract`, derived from `BuildConfig.APPLICATION_ID`, and the `resValue` + * strings the XML descriptors read. Drift between them is invisible at build + * time and shows up as an account the sync framework will not trigger — the + * silent-no-op failure mode `docs/SYNC.md` warns about, with nothing in the log. + */ +@RunWith(AndroidJUnit4::class) +class SyncContractTest { + + private val context: Context get() = ApplicationProvider.getApplicationContext() + + @Test + fun theAccountTypeMatchesTheAuthenticatorDescriptor() { + assertThat(SyncContract.ACCOUNT_TYPE) + .isEqualTo(context.getString(R.string.account_type)) + } + + @Test + fun theAuthorityMatchesTheSyncAdapterDescriptor() { + assertThat(SyncContract.AUTHORITY) + .isEqualTo(context.getString(R.string.sync_authority)) + } + + @Test + fun theAuthorityMatchesTheStubProviderInTheManifest() { + // The provider is what makes the authority real; a mismatch here means + // requestSync addresses nothing. + val provider = context.packageManager + .resolveContentProvider(SyncContract.AUTHORITY, 0) + assertThat(provider).isNotNull() + assertThat(provider!!.name).isEqualTo(SyncStubProvider::class.java.name) + } +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index a50a376..95b6e5a 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -16,6 +16,29 @@ + + + + + + @@ -43,6 +66,7 @@ android:icon="@mipmap/ic_launcher" android:label="@string/app_name" android:localeConfig="@xml/locales_config" + android:networkSecurityConfig="@xml/network_security_config" android:roundIcon="@mipmap/ic_launcher_round" android:supportsRtl="true" android:theme="@style/Theme.Agendula" @@ -95,6 +119,57 @@ + + + + + + + + + + + + + + + + + + + + + + + by preferencesData name = "agendula_prefs", ) +/** See [CredentialsDataStore] for why this is a separate file. */ +private val Context.credentialsDataStore: DataStore by preferencesDataStore( + name = CREDENTIALS_DATASTORE, +) + +/** + * Named here and in `backup_rules.xml` / `data_extraction_rules.xml`, which + * exclude `datastore/$CREDENTIALS_DATASTORE.preferences_pb` by this name. + */ +const val CREDENTIALS_DATASTORE = "agendula_credentials" + @Module @InstallIn(SingletonComponent::class) abstract class DataBindModule { @@ -55,6 +66,12 @@ object DataProvideModule { fun provideDataStore(@ApplicationContext context: Context): DataStore = context.agendulaDataStore + @Provides + @Singleton + @CredentialsDataStore + fun provideCredentialsDataStore(@ApplicationContext context: Context): DataStore = + context.credentialsDataStore + @Provides @Singleton fun provideTasksDatabase(@ApplicationContext context: Context): TasksDatabase = diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/di/Qualifiers.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/di/Qualifiers.kt index e42dfcf..bd3765e 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/di/Qualifiers.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/di/Qualifiers.kt @@ -16,3 +16,17 @@ annotation class IoDispatcher @Qualifier @Retention(AnnotationRetention.BINARY) annotation class ApplicationScope + +/** + * Marks the DataStore holding **only** the Keystore-encrypted app passwords. + * + * A separate file from `agendula_prefs` on purpose: Auto Backup includes + * `datastore/`, and a restored ciphertext is permanently undecryptable because + * Keystore keys are non-exportable. Its own file is what lets the backup rules + * exclude the credentials and nothing else — `docs/SYNC.md` is explicit that + * excluding the whole database or all of DataStore would trade a latent bug for + * a live one. + */ +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class CredentialsDataStore diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/sync/CalDavAccounts.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/sync/CalDavAccounts.kt new file mode 100644 index 0000000..c133b58 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/sync/CalDavAccounts.kt @@ -0,0 +1,91 @@ +package de.jeanlucmakiola.agendula.data.sync + +import android.accounts.Account +import android.accounts.AccountManager +import android.content.ContentResolver +import android.content.Context +import android.os.Bundle +import dagger.hilt.android.qualifiers.ApplicationContext +import de.jeanlucmakiola.agendula.BuildConfig +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Identifiers shared between Kotlin and the two XML descriptors. + * + * Derived from `applicationId`, so the debug and `releaseTest` builds get their + * own account type and authority and can be installed alongside the real app + * without their accounts colliding. ⚠️ `res/xml/authenticator.xml` and + * `res/xml/sync_adapter.xml` cannot read `BuildConfig`, so they use string + * resources generated by `resValue` in `app/build.gradle.kts` — the two must be + * changed together. + */ +object SyncContract { + val ACCOUNT_TYPE: String = BuildConfig.APPLICATION_ID + ".caldav" + val AUTHORITY: String = BuildConfig.APPLICATION_ID + ".sync" +} + +/** + * Agendula's CalDAV accounts, as the system sees them. + * + * The Room `accounts` table is the source of truth for everything about an + * account; this is only the system-visible half — the entry in Settings, and the + * handle the sync framework needs to trigger us. + */ +@Singleton +class CalDavAccounts @Inject constructor( + @ApplicationContext private val context: Context, +) { + + private val accountManager get() = AccountManager.get(context) + + fun all(): List = + accountManager.getAccountsByType(SyncContract.ACCOUNT_TYPE).toList() + + fun find(name: String): Account? = all().firstOrNull { it.name == name } + + /** + * Registers [name] with the system and turns sync on for it. + * + * No password is handed to `AccountManager`: it stores them as plain `TEXT`. + * The app password goes to [CredentialStore], keyed by the Room account id. + * + * @return false if an account with this name already exists + */ + fun add(name: String, roomAccountId: Long): Boolean { + val account = Account(name, SyncContract.ACCOUNT_TYPE) + val userData = Bundle().apply { putString(KEY_ROOM_ACCOUNT_ID, roomAccountId.toString()) } + if (!accountManager.addAccountExplicitly(account, null, userData)) return false + + // All three are among the calls that return silently with no registered + // sync adapter — see SyncAdapterService. They work because we register one. + ContentResolver.setIsSyncable(account, SyncContract.AUTHORITY, 1) + ContentResolver.setSyncAutomatically(account, SyncContract.AUTHORITY, true) + return true + } + + /** + * The Room account id for [account], or null. + * + * ⚠️ `getUserData` returns null while the device is locked, so a + * boot-triggered sync has to wait for unlock rather than treat this as + * "account gone". + */ + fun roomAccountId(account: Account): Long? = + accountManager.getUserData(account, KEY_ROOM_ACCOUNT_ID)?.toLongOrNull() + + fun requestSync(account: Account) { + ContentResolver.requestSync( + account, + SyncContract.AUTHORITY, + Bundle().apply { + putBoolean(ContentResolver.SYNC_EXTRAS_MANUAL, true) + putBoolean(ContentResolver.SYNC_EXTRAS_EXPEDITED, true) + }, + ) + } + + private companion object { + const val KEY_ROOM_ACCOUNT_ID = "roomAccountId" + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/sync/CredentialStore.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/sync/CredentialStore.kt new file mode 100644 index 0000000..e9c6946 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/sync/CredentialStore.kt @@ -0,0 +1,171 @@ +package de.jeanlucmakiola.agendula.data.sync + +import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyPermanentlyInvalidatedException +import android.security.keystore.KeyProperties +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import de.jeanlucmakiola.agendula.data.di.CredentialsDataStore +import kotlinx.coroutines.flow.first +import java.io.IOException +import java.security.GeneralSecurityException +import java.security.ProviderException +import java.security.KeyStore +import java.util.Base64 +import javax.crypto.AEADBadTagException +import javax.crypto.Cipher +import javax.crypto.KeyGenerator +import javax.crypto.SecretKey +import javax.crypto.spec.GCMParameterSpec +import javax.inject.Inject +import javax.inject.Singleton + +/** + * App passwords, encrypted with a hardware-backed Keystore key. + * + * `androidx.security:security-crypto` is **formally deprecated and terminal** — + * deprecated at 1.1.0-alpha07, shipped deprecated in stable 1.1.0, with release + * notes saying there will be no further releases — and its successor + * `datastore-tink` is alpha. So: Keystore `AES/GCM/NoPadding` directly, blob in + * DataStore. + * + * Be honest about what this buys. `AccountManager` stores passwords as plain + * `TEXT` — there is no encryption or hashing anywhere in AOSP — so file-based + * encryption plus a same-signature check is the whole boundary there. That is + * DAVx5's posture and it is defensible, but it is not secure storage. This is + * better, and the difference is worth the ~80 lines. + * + * Three deliberate non-choices: + * - `setUserAuthenticationRequired` is left at its default of `false`. Requiring + * a device unlock per decryption makes background sync impossible. + * - `setUnlockedDeviceRequired` is **not** set, for the same reason. + * - A failure to decrypt is *never* a crash. It means re-authenticate. + */ +@Singleton +class CredentialStore @Inject constructor( + @CredentialsDataStore private val dataStore: DataStore, +) { + + /** What came back for an account. */ + sealed interface Secret { + data class Present(val value: String) : Secret + + data object Absent : Secret + + /** + * The ciphertext exists but can no longer be decrypted, so the only + * recovery is to sign in again. + * + * Reached by a restored backup (Keystore keys are non-exportable, so a + * restored blob is permanently undecryptable — which is why the blob is + * excluded from backup), by the key being invalidated when the user + * changes their lock screen, or by corruption. + */ + data class Unrecoverable(val reason: String) : Secret + } + + /** + * Stores [appPassword] for [accountId]. + * + * @return false when the Keystore could not be used at all. A wedged or + * degraded keystore throws [ProviderException], which is a `RuntimeException` + * and would otherwise take down the account-add flow — the same "never + * crash over this" rule [get] follows. + */ + suspend fun put(accountId: Long, appPassword: String): Boolean = try { + val cipher = Cipher.getInstance(TRANSFORMATION).apply { init(Cipher.ENCRYPT_MODE, key()) } + // The IV travels with the ciphertext. GCM requires a unique IV per + // encryption under the same key; letting the provider generate it is the + // only way to be sure of that. + val payload = cipher.iv + cipher.doFinal(appPassword.toByteArray(Charsets.UTF_8)) + dataStore.edit { it[keyFor(accountId)] = Base64.getEncoder().encodeToString(payload) } + true + } catch (e: GeneralSecurityException) { + false + } catch (e: ProviderException) { + false + } catch (e: IOException) { + false + } + + suspend fun get(accountId: Long): Secret { + val stored = dataStore.data.first()[keyFor(accountId)] ?: return Secret.Absent + return try { + val payload = Base64.getDecoder().decode(stored) + val cipher = Cipher.getInstance(TRANSFORMATION).apply { + init( + Cipher.DECRYPT_MODE, + key(), + GCMParameterSpec(TAG_BITS, payload, 0, IV_BYTES), + ) + } + Secret.Present( + String( + cipher.doFinal(payload, IV_BYTES, payload.size - IV_BYTES), + Charsets.UTF_8, + ), + ) + } catch (e: KeyPermanentlyInvalidatedException) { + // The lock screen changed, or the key was otherwise invalidated. + Secret.Unrecoverable(e.message ?: "the encryption key was invalidated") + } catch (e: AEADBadTagException) { + // Wrong key or tampered ciphertext — the restored-backup case. + Secret.Unrecoverable(e.message ?: "the stored credential could not be decrypted") + } catch (e: GeneralSecurityException) { + Secret.Unrecoverable(e.message ?: "the stored credential could not be read") + } catch (e: IllegalArgumentException) { + // Not valid Base64 at all — a truncated or hand-edited blob. + Secret.Unrecoverable(e.message ?: "the stored credential is malformed") + } catch (e: ProviderException) { + // ⚠️ AndroidKeyStore signals keystore-level failure ("Keystore + // operation failed", "Failed to load key") with this — a + // RuntimeException, so none of the catches above match it. On a + // device with a degraded keystore it would crash the sync worker + // instead of prompting a re-authentication. + Secret.Unrecoverable(e.message ?: "the device keystore is unavailable") + } catch (e: IOException) { + // KeyStore.load declares it. + Secret.Unrecoverable(e.message ?: "the device keystore could not be opened") + } + } + + suspend fun clear(accountId: Long) { + dataStore.edit { it.remove(keyFor(accountId)) } + } + + /** Every stored credential. Used when the last account goes away. */ + suspend fun clearAll() { + dataStore.edit { it.clear() } + } + + private fun keyFor(accountId: Long) = stringPreferencesKey("caldav_app_password_$accountId") + + private fun key(): SecretKey { + val keyStore = KeyStore.getInstance(KEYSTORE).apply { load(null) } + (keyStore.getEntry(KEY_ALIAS, null) as? KeyStore.SecretKeyEntry)?.let { return it.secretKey } + + return KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, KEYSTORE).apply { + init( + KeyGenParameterSpec.Builder( + KEY_ALIAS, + KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT, + ) + .setBlockModes(KeyProperties.BLOCK_MODE_GCM) + .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) + // Not calling setUserAuthenticationRequired / + // setUnlockedDeviceRequired is the point — see the class doc. + .build(), + ) + }.generateKey() + } + + private companion object { + const val KEYSTORE = "AndroidKeyStore" + const val KEY_ALIAS = "agendula.caldav.credentials" + const val TRANSFORMATION = "AES/GCM/NoPadding" + const val IV_BYTES = 12 + const val TAG_BITS = 128 + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/sync/SyncAdapterService.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/sync/SyncAdapterService.kt new file mode 100644 index 0000000..81cfd78 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/sync/SyncAdapterService.kt @@ -0,0 +1,110 @@ +package de.jeanlucmakiola.agendula.data.sync + +import android.accounts.Account +import android.app.Service +import android.content.AbstractThreadedSyncAdapter +import android.content.ContentProviderClient +import android.content.Context +import android.content.Intent +import android.content.SyncResult +import android.os.Bundle +import android.os.IBinder +import androidx.work.Data +import androidx.work.ExistingWorkPolicy +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.WorkInfo +import androidx.work.WorkManager +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeoutOrNull +import kotlin.time.Duration.Companion.minutes + +/** + * The sync adapter whose entire job is to start a WorkManager job and wait. + * + * DAVx5's own comment describes the same design: *"We use the sync adapter + * framework only for the trigger, actual syncing is implemented with + * WorkManager."* + * + * ⚠️ Registering this is **not optional decoration**. `docs/SYNC.md` establishes + * that `ContentService.hasAuthorityAccess()` gates `requestSync`, + * `setSyncAutomatically`, `addPeriodicSync`, `setIsSyncable`, `getSyncStatus` and + * seven more behind a compat change that is on for targetSdk ≥ 34 — which we + * are. With no sync adapter registered for our authority, every one of those + * calls **returns silently**: no exception, no log, and it passes on a + * Robolectric shadow. The visible result is an account permanently reading "Sync + * off for all items" with a greyed-out "Sync now", and it is documented on no + * Android behaviour-changes page. + * + * The greying-out is why the app ships its own sync button regardless: + * `enabledSyncNowMenu()` needs at least one checked authority switch, and ours + * is `userVisible="false"`. + */ +class SyncAdapterService : Service() { + + private val adapter by lazy { CalDavSyncAdapter(applicationContext) } + + override fun onBind(intent: Intent?): IBinder = adapter.syncAdapterBinder +} + +private class CalDavSyncAdapter(context: Context) : + AbstractThreadedSyncAdapter(context, /* autoInitialize = */ true) { + + override fun onPerformSync( + account: Account, + extras: Bundle, + authority: String, + provider: ContentProviderClient, + syncResult: SyncResult, + ) { + val workManager = WorkManager.getInstance(context) + val uniqueName = SyncWorker.uniqueNameFor(account.name) + val request = OneTimeWorkRequestBuilder() + .setInputData(Data.Builder().putString(SyncWorker.KEY_ACCOUNT_NAME, account.name).build()) + .build() + + workManager.enqueueUniqueWork( + uniqueName, + // KEEP, not REPLACE: a periodic trigger arriving while a manual sync + // is mid-flight must not cancel it and lose the cursor. + ExistingWorkPolicy.KEEP, + request, + ) + + // Block this thread until the work reaches a terminal state. The framework + // treats onPerformSync returning as "the sync is done", so returning early + // would make every sync look instantaneous and defeat the back-off it + // applies on failure. runBlocking is fine here: onPerformSync is already + // called on a background thread the framework owns. + // + // ⚠️ Watch the **unique work name**, not the request id. enqueueUniqueWork + // is asynchronous — the WorkSpec row is not written by the time the next + // line runs — so a flow keyed on the id emits null for an unknown id and + // the wait returns immediately, having waited for nothing. And under + // KEEP, when a run is already in flight, our request is never enqueued at + // all and its id stays unknown forever. Keying on the name handles both: + // it waits for whichever run is actually happening. + val infos = runCatching { + runBlocking { + withTimeoutOrNull(WORKER_TIMEOUT_MINUTES.minutes) { + workManager.getWorkInfosForUniqueWorkFlow(uniqueName) + .first { infos -> infos.isNotEmpty() && infos.all { it.state.isFinished } } + } + } + }.getOrNull() + + // Counted as a soft error: the engine's own per-collection and + // per-resource isolation decides what is actually fatal, and telling the + // framework otherwise would have it back off the whole account. Being + // deduplicated by KEEP is *not* a failure — the sync is happening, this + // trigger simply joined the one already running. + val timedOut = infos == null + val failed = infos?.any { it.state == WorkInfo.State.FAILED } == true + if (timedOut || failed) syncResult.stats.numIoExceptions++ + } + + private companion object { + /** DAVx5 uses the same ceiling; an ordinary worker is documented for < 10 min. */ + const val WORKER_TIMEOUT_MINUTES = 10L + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/sync/SyncAuthenticator.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/sync/SyncAuthenticator.kt new file mode 100644 index 0000000..1bb571d --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/sync/SyncAuthenticator.kt @@ -0,0 +1,109 @@ +package de.jeanlucmakiola.agendula.data.sync + +import android.accounts.AbstractAccountAuthenticator +import android.accounts.Account +import android.accounts.AccountAuthenticatorResponse +import android.accounts.AccountManager +import android.app.Service +import android.content.Context +import android.content.Intent +import android.os.Bundle +import android.os.IBinder + +/** + * The account authenticator. + * + * Agendula holds no auth tokens — a CalDAV account is a username and an app + * password, and the password lives in [CredentialStore], not here. + * `AccountManager` stores passwords as plain `TEXT`; there is no encryption or + * hashing anywhere in AOSP, so nothing secret is handed to it. + * + * `docs/SYNC.md` corrects the reason this exists. It is **not** required by any + * provider — that argument was circular. The real reasons: a stable account + * identity a third-party engine could address, presence in system Settings, and + * the sync framework as a change trigger. + */ +class SyncAuthenticator(private val context: Context) : AbstractAccountAuthenticator(context) { + + /** + * ⚠️ Refuses until the account-add UI exists (`SYNC-PLAN.md` chunk 2d). + * + * The authenticator service is exported and registered, so Settings → + * Accounts → Add account lists Agendula **today**. Handing back an intent to + * a screen that does not yet handle [ACTION_ADD_ACCOUNT] would open the + * ordinary home screen while Settings waits forever on a response nothing + * answers. A refusal the user can read is strictly better than a hang; 2d + * replaces this with the real intent and answers [response]. + */ + override fun addAccount( + response: AccountAuthenticatorResponse?, + accountType: String?, + authTokenType: String?, + requiredFeatures: Array?, + options: Bundle?, + ): Bundle = unsupported("Add a CalDAV account from inside Agendula, under Settings") + + override fun editProperties( + response: AccountAuthenticatorResponse?, + accountType: String?, + ): Bundle = Bundle() + + /** + * ⚠️ Never `null`. `AbstractAccountAuthenticator.Transport` reads a null + * return as "I will answer asynchronously via the response", and nothing here + * ever does — the caller's `AccountManagerFuture` would never complete. + */ + override fun confirmCredentials( + response: AccountAuthenticatorResponse?, + account: Account?, + options: Bundle?, + ): Bundle = unsupported("Agendula does not confirm credentials from the system UI") + + /** No token type: this is Basic/Digest against a CalDAV server. */ + override fun getAuthToken( + response: AccountAuthenticatorResponse?, + account: Account?, + authTokenType: String?, + options: Bundle?, + ): Bundle = unsupported("Agendula accounts do not use auth tokens") + + override fun getAuthTokenLabel(authTokenType: String?): String? = null + + /** Never `null`, for the reason given on [confirmCredentials]. */ + override fun updateCredentials( + response: AccountAuthenticatorResponse?, + account: Account?, + authTokenType: String?, + options: Bundle?, + ): Bundle = unsupported("Re-authenticate from inside Agendula, under Settings") + + override fun hasFeatures( + response: AccountAuthenticatorResponse?, + account: Account?, + features: Array?, + ): Bundle = Bundle().apply { putBoolean(AccountManager.KEY_BOOLEAN_RESULT, false) } + + private fun unsupported(message: String) = Bundle().apply { + putInt(AccountManager.KEY_ERROR_CODE, AccountManager.ERROR_CODE_UNSUPPORTED_OPERATION) + putString(AccountManager.KEY_ERROR_MESSAGE, message) + } + + companion object { + /** Sent to `MainActivity` when the system asks us to add an account (chunk 2d). */ + const val ACTION_ADD_ACCOUNT = "de.jeanlucmakiola.agendula.ADD_ACCOUNT" + } +} + +/** + * Binds [SyncAuthenticator] for the system. + * + * Exported and guarded by `android.permission.ACCOUNT_MANAGER` — note that + * `android.permission.ACCOUNT_AUTHENTICATOR`, which the obvious guess would + * reach for, **does not exist**. + */ +class AuthenticatorService : Service() { + + private val authenticator by lazy { SyncAuthenticator(this) } + + override fun onBind(intent: Intent?): IBinder? = authenticator.iBinder +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/sync/SyncStubProvider.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/sync/SyncStubProvider.kt new file mode 100644 index 0000000..b5dd7f1 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/sync/SyncStubProvider.kt @@ -0,0 +1,52 @@ +package de.jeanlucmakiola.agendula.data.sync + +import android.content.ContentProvider +import android.content.ContentValues +import android.database.Cursor +import android.net.Uri + +/** + * A `ContentProvider` that stores nothing. + * + * It exists because **a sync adapter is registered against a content + * authority**, and Agendula publishes no provider — `:provider` was deleted when + * we took our own Room store (`docs/OWN-STORE.md`). Without an authority there + * is nothing for `` to name, nothing for + * `ContentResolver.requestSync` to address, and nothing for system Settings to + * render a sync switch against. + * + * `docs/SYNC.md` establishes that the sync-adapter registration is not optional: + * `ContentService.hasAuthorityAccess()` gates `requestSync`, + * `setSyncAutomatically`, `addPeriodicSync`, `setIsSyncable` and seven more + * behind a compat change that is **on for targetSdk ≥ 34**, and with nothing + * registered every one of those calls returns silently — no exception, no log, + * and it passes on a Robolectric shadow. This provider is the cheapest way to + * hold up the other end of that requirement. + * + * Not exported, and every method is a no-op. Real data lives in Room. + */ +class SyncStubProvider : ContentProvider() { + + override fun onCreate() = true + + override fun query( + uri: Uri, + projection: Array?, + selection: String?, + selectionArgs: Array?, + sortOrder: String?, + ): Cursor? = null + + override fun getType(uri: Uri): String? = null + + override fun insert(uri: Uri, values: ContentValues?): Uri? = null + + override fun delete(uri: Uri, selection: String?, selectionArgs: Array?) = 0 + + override fun update( + uri: Uri, + values: ContentValues?, + selection: String?, + selectionArgs: Array?, + ) = 0 +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/sync/SyncWorker.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/sync/SyncWorker.kt new file mode 100644 index 0000000..6a08951 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/sync/SyncWorker.kt @@ -0,0 +1,40 @@ +package de.jeanlucmakiola.agendula.data.sync + +import android.content.Context +import androidx.hilt.work.HiltWorker +import androidx.work.CoroutineWorker +import androidx.work.WorkerParameters +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject + +/** + * Where sync actually happens. + * + * A stub until chunk 3 — the account plumbing has to exist and be provably wired + * before there is anywhere for an engine to run. + * + * Two things about this worker are decided already and are not chunk 3's to + * revisit. It is a **`CoroutineWorker` with no foreground service**: an ordinary + * worker is documented for under 10 minutes, and escalating to `setForeground` + * pulls in `FOREGROUND_SERVICE_DATA_SYNC`, the Android 15 six-hours-per-24 + * `dataSync` budget whose failure mode is a fatal `RemoteServiceException`, and + * a Play requirement for a video demo per declared FGS type. And it must be + * **chunked and resumable** — the sync cursor is persisted per collection so a + * killed worker resumes rather than restarts, because under WorkManager process + * death mid-sync is routine rather than exotic. + */ +@HiltWorker +class SyncWorker @AssistedInject constructor( + @Assisted context: Context, + @Assisted parameters: WorkerParameters, +) : CoroutineWorker(context, parameters) { + + override suspend fun doWork(): Result = Result.success() + + companion object { + /** One in-flight sync per account, so a manual trigger cannot pile up. */ + fun uniqueNameFor(accountName: String) = "caldav-sync:$accountName" + + const val KEY_ACCOUNT_NAME = "accountName" + } +} diff --git a/app/src/main/res/xml/authenticator.xml b/app/src/main/res/xml/authenticator.xml new file mode 100644 index 0000000..78a0c92 --- /dev/null +++ b/app/src/main/res/xml/authenticator.xml @@ -0,0 +1,12 @@ + + + diff --git a/app/src/main/res/xml/backup_rules.xml b/app/src/main/res/xml/backup_rules.xml index 8d670da..20325a8 100644 --- a/app/src/main/res/xml/backup_rules.xml +++ b/app/src/main/res/xml/backup_rules.xml @@ -19,4 +19,15 @@ + + diff --git a/app/src/main/res/xml/data_extraction_rules.xml b/app/src/main/res/xml/data_extraction_rules.xml index c7530b1..3ddab15 100644 --- a/app/src/main/res/xml/data_extraction_rules.xml +++ b/app/src/main/res/xml/data_extraction_rules.xml @@ -11,11 +11,14 @@ + + + diff --git a/app/src/main/res/xml/network_security_config.xml b/app/src/main/res/xml/network_security_config.xml new file mode 100644 index 0000000..41a78a6 --- /dev/null +++ b/app/src/main/res/xml/network_security_config.xml @@ -0,0 +1,34 @@ + + + + + + + + + + diff --git a/app/src/main/res/xml/sync_adapter.xml b/app/src/main/res/xml/sync_adapter.xml new file mode 100644 index 0000000..9b67c0b --- /dev/null +++ b/app/src/main/res/xml/sync_adapter.xml @@ -0,0 +1,18 @@ + + + diff --git a/docs/SYNC-PLAN.md b/docs/SYNC-PLAN.md index 84d86a6..f03dfe6 100644 --- a/docs/SYNC-PLAN.md +++ b/docs/SYNC-PLAN.md @@ -58,7 +58,8 @@ Nextcloud/Radicale/Baïkal in chunk 5. | 1 | **Mapper** — VTODO ↔ Room | nothing | Unknown properties survive a read-modify-write cycle | | 2a | **Vendored DAV foundation** | nothing | Vendor a tree we can actually maintain, and know which of its defects are real | | 2b | **Discovery and auth protocol** | 2a | The two collection filters are inversions of the obvious rule | -| 2c | **The Android account layer** | 2b | Never send a credential into an unvalidated redirect chain | +| 2c | **The Android account layer** | 2b | Registering the sync adapter, or every `ContentResolver` sync call is a silent no-op | +| 2d | **Account-add UI** | 2c | Never send a credential into an unvalidated redirect chain | | 3 | **Engine core** — read, write, conflicts | 1, 2 | A failed resource must not fail the collection | | 4 | **Incremental sync + scheduling** | 3 | Never persist a sync token before the bodies it covers are applied | | 5 | **Hardening, compliance, real servers** | 1–4 | Ship no licence violation, and no retry loop on a dead app password | @@ -228,6 +229,54 @@ resourcetype, Nextcloud's trashed calendar, the absent/empty component set, and ## Chunk 2c — the Android account layer +Everything that needs the platform and nothing that needs a screen: Keystore +credentials, `AccountManager`, the stub sync adapter, the manifest, and the +network security config. The account-add UI is 2d — it is a design task, it +needs the `material-3` skill, and it is the piece that needs an on-device review. + +### ⚠️ A stub `ContentProvider` turned out to be required + +Not in the original plan, and it is load-bearing. A sync adapter registers +against a **content authority**, and Agendula publishes no provider — `:provider` +was deleted when we took our own Room store. With no authority there is nothing +for `` to name, nothing for +`requestSync` to address, and nothing for Settings to render a switch against. +`SyncStubProvider` stores nothing and exists solely to hold up that end. + +### Other things settled here + +- **The account type and authority are per build variant**, generated by + `resValue`. Two installs cannot own the same account type, so a debug build + sharing the release build's would fight it. `SyncContractTest` asserts the + Kotlin constants and the generated strings still agree — drift between them is + invisible at build time and surfaces as an account the framework will not + trigger. +- **The credential blob is the one thing excluded from backup.** It lives in its + own DataStore file for exactly that reason. Keystore keys are non-exportable, + so a restored ciphertext is permanently undecryptable; excluding it means the + user signs in again, which is the honest outcome. Excluding the database or all + of DataStore would trade a latent bug for a live one. +- ⚠️ **The network security config trusts the whole user CA store, for all + traffic.** `SYNC.md` specifies it, and without it a correctly installed private + CA is not trusted at all — which is the self-hosting case. But `base-config` is + the widest form: any CA in the user store can intercept the CalDAV connection + and read the app password from the `Authorization` header. **Chunk 5's + cert4android should replace this block, not sit beside it** — its per-connection + approval is the narrow version of the same capability. +- **`FOREGROUND_SERVICE` appears in the merged manifest** without being declared, + from `work-runtime`. That is not the FGS route: below API 31 WorkManager + implements expedited work with a foreground service, and minSdk is 29. Noted in + the manifest because it shows in F-Droid's permission diff. + +**Not run:** the instrumented tests here (`CredentialStoreTest`, +`SyncContractTest`) compile but have not been executed — `CLAUDE.md` reserves +device interaction for when it is explicitly asked for. They need a device run +before chunk 2 is done. + +--- + +## Chunk 2d — the account-add UI + **Goal:** the app can add a CalDAV account and list its VTODO collections. No syncing yet. diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 76da49e..f3c027b 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -26,7 +26,12 @@ espressoCore = "3.7.0" kotlinxDatetime = "0.7.0" kotlinxCoroutines = "1.10.2" turbine = "1.2.0" -hiltNavigationCompose = "1.3.0" +# androidx.hilt — one version for the whole group. hilt-work and +# hilt-navigation-compose splitting versions inside a group is the kind of thing +# that resolves fine and then fails at runtime. +androidxHilt = "1.3.0" +# WorkManager: sync runs here, triggered *through* the sync-adapter framework. +work = "2.11.2" navigationCompose = "2.9.0" lifecycleCompose = "2.10.0" androidxTestRules = "1.7.0" @@ -125,7 +130,10 @@ kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-cor turbine = { group = "app.cash.turbine", name = "turbine", version.ref = "turbine" } # Hilt navigation-compose (for hiltViewModel() in Composables) -androidx-hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navigation-compose", version.ref = "hiltNavigationCompose" } +androidx-hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navigation-compose", version.ref = "androidxHilt" } +androidx-hilt-work = { group = "androidx.hilt", name = "hilt-work", version.ref = "androidxHilt" } +androidx-hilt-compiler = { group = "androidx.hilt", name = "hilt-compiler", version.ref = "androidxHilt" } +androidx-work-runtime-ktx = { group = "androidx.work", name = "work-runtime-ktx", version.ref = "work" } # Navigation-compose (the NavHost / back stack) androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" }