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.
This commit is contained in:
@@ -33,6 +33,14 @@ android {
|
|||||||
versionName = "0.4.0"
|
versionName = "0.4.0"
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
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 {
|
signingConfigs {
|
||||||
@@ -66,6 +74,8 @@ android {
|
|||||||
debug {
|
debug {
|
||||||
applicationIdSuffix = ".debug"
|
applicationIdSuffix = ".debug"
|
||||||
isMinifyEnabled = false
|
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
|
// A locally-installable twin of `release`: same R8 shrinking + obfuscation
|
||||||
// and resource shrinking, but debug-signed and given its own applicationId
|
// and resource shrinking, but debug-signed and given its own applicationId
|
||||||
@@ -82,6 +92,8 @@ android {
|
|||||||
isMinifyEnabled = true
|
isMinifyEnabled = true
|
||||||
isShrinkResources = true
|
isShrinkResources = true
|
||||||
matchingFallbacks += "release"
|
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 {
|
buildFeatures {
|
||||||
compose = true
|
compose = true
|
||||||
buildConfig = 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
|
// 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)
|
implementation(libs.androidx.navigation.compose)
|
||||||
ksp(libs.hilt.compiler)
|
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
|
// 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
|
// removed RecurrenceSet. rfc5545-datetime comes with it and is part of its
|
||||||
// API surface, so it isn't declared separately.
|
// API surface, so it isn't declared separately.
|
||||||
@@ -184,6 +207,8 @@ dependencies {
|
|||||||
|
|
||||||
// Vendored dav4jvm — the CalDAV protocol layer. See dav/PROVENANCE.md.
|
// Vendored dav4jvm — the CalDAV protocol layer. See dav/PROVENANCE.md.
|
||||||
implementation(project(":dav"))
|
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
|
// :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
|
// 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
|
// with no framework, and android.jar's stub factory returns null under
|
||||||
|
|||||||
@@ -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<Preferences>
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,6 +16,29 @@
|
|||||||
<uses-permission android:name="org.tasks.permission.READ_TASKS" />
|
<uses-permission android:name="org.tasks.permission.READ_TASKS" />
|
||||||
<uses-permission android:name="org.tasks.permission.WRITE_TASKS" />
|
<uses-permission android:name="org.tasks.permission.WRITE_TASKS" />
|
||||||
|
|
||||||
|
<!-- CalDAV sync. ACCESS_NETWORK_STATE is merged in by work-runtime anyway,
|
||||||
|
but it shows in F-Droid's permission diff, so declare it deliberately
|
||||||
|
rather than letting it appear from nowhere.
|
||||||
|
|
||||||
|
READ_SYNC_SETTINGS / WRITE_SYNC_SETTINGS are what the ContentResolver
|
||||||
|
sync APIs need. No FOREGROUND_SERVICE: sync is a plain worker, and the
|
||||||
|
dataSync FGS type would bring the Android 15 six-hours-per-24 budget
|
||||||
|
(whose failure mode is a fatal RemoteServiceException) and a Play
|
||||||
|
requirement for a video demo.
|
||||||
|
|
||||||
|
Two more permissions appear in the merged manifest without being
|
||||||
|
declared here, and both come from work-runtime: WAKE_LOCK, and
|
||||||
|
FOREGROUND_SERVICE. The latter is not us taking the FGS route — below
|
||||||
|
API 31 WorkManager implements expedited work with a foreground service,
|
||||||
|
and minSdk is 29, so it is load-bearing for the "Sync now" button.
|
||||||
|
Removing it with tools:node="remove" would break expedited work on
|
||||||
|
exactly the older devices that need it most. Noted because it shows in
|
||||||
|
F-Droid's permission diff and would otherwise look unexplained. -->
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||||
|
<uses-permission android:name="android.permission.READ_SYNC_SETTINGS" />
|
||||||
|
<uses-permission android:name="android.permission.WRITE_SYNC_SETTINGS" />
|
||||||
|
|
||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||||
<!-- Exact due-time reminders: USE_EXACT_ALARM on 33+, SCHEDULE on 31-32. -->
|
<!-- Exact due-time reminders: USE_EXACT_ALARM on 33+, SCHEDULE on 31-32. -->
|
||||||
@@ -43,6 +66,7 @@
|
|||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher"
|
||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
android:localeConfig="@xml/locales_config"
|
android:localeConfig="@xml/locales_config"
|
||||||
|
android:networkSecurityConfig="@xml/network_security_config"
|
||||||
android:roundIcon="@mipmap/ic_launcher_round"
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
android:supportsRtl="true"
|
android:supportsRtl="true"
|
||||||
android:theme="@style/Theme.Agendula"
|
android:theme="@style/Theme.Agendula"
|
||||||
@@ -95,6 +119,57 @@
|
|||||||
</intent-filter>
|
</intent-filter>
|
||||||
</receiver>
|
</receiver>
|
||||||
|
|
||||||
|
<!-- Sync plumbing. The stub provider exists only to give the sync
|
||||||
|
adapter an authority to register against: Agendula publishes no real
|
||||||
|
ContentProvider since :provider was deleted, and without an authority
|
||||||
|
ContentService.hasAuthorityAccess() makes every ContentResolver sync
|
||||||
|
call a silent no-op at targetSdk >= 34. -->
|
||||||
|
<provider
|
||||||
|
android:name=".data.sync.SyncStubProvider"
|
||||||
|
android:authorities="${applicationId}.sync"
|
||||||
|
android:exported="false"
|
||||||
|
android:syncable="true" />
|
||||||
|
|
||||||
|
<!-- Exported and guarded by ACCOUNT_MANAGER. Note that
|
||||||
|
android.permission.ACCOUNT_AUTHENTICATOR does not exist. -->
|
||||||
|
<service
|
||||||
|
android:name=".data.sync.AuthenticatorService"
|
||||||
|
android:exported="true"
|
||||||
|
android:permission="android.permission.ACCOUNT_MANAGER">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.accounts.AccountAuthenticator" />
|
||||||
|
</intent-filter>
|
||||||
|
<meta-data
|
||||||
|
android:name="android.accounts.AccountAuthenticator"
|
||||||
|
android:resource="@xml/authenticator" />
|
||||||
|
</service>
|
||||||
|
|
||||||
|
<service
|
||||||
|
android:name=".data.sync.SyncAdapterService"
|
||||||
|
android:exported="true"
|
||||||
|
android:permission="android.permission.BIND_SYNC_ADAPTER">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.content.SyncAdapter" />
|
||||||
|
</intent-filter>
|
||||||
|
<meta-data
|
||||||
|
android:name="android.content.SyncAdapter"
|
||||||
|
android:resource="@xml/sync_adapter" />
|
||||||
|
</service>
|
||||||
|
|
||||||
|
<!-- WorkManager's on-demand initialisation. Removing the default
|
||||||
|
initializer is what lets AgendulaApp supply a HiltWorkerFactory, so
|
||||||
|
@HiltWorker workers can take injected dependencies. -->
|
||||||
|
<provider
|
||||||
|
android:name="androidx.startup.InitializationProvider"
|
||||||
|
android:authorities="${applicationId}.androidx-startup"
|
||||||
|
android:exported="false"
|
||||||
|
tools:node="merge">
|
||||||
|
<meta-data
|
||||||
|
android:name="androidx.work.WorkManagerInitializer"
|
||||||
|
android:value="androidx.startup"
|
||||||
|
tools:node="remove" />
|
||||||
|
</provider>
|
||||||
|
|
||||||
<!-- Persists the per-app language on API < 33, where the platform
|
<!-- Persists the per-app language on API < 33, where the platform
|
||||||
per-app-languages API is unavailable. On 33+ this is a no-op. -->
|
per-app-languages API is unavailable. On 33+ this is a no-op. -->
|
||||||
<service
|
<service
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
package de.jeanlucmakiola.agendula
|
package de.jeanlucmakiola.agendula
|
||||||
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
|
import androidx.hilt.work.HiltWorkerFactory
|
||||||
import androidx.lifecycle.ProcessLifecycleOwner
|
import androidx.lifecycle.ProcessLifecycleOwner
|
||||||
|
import androidx.work.Configuration
|
||||||
import dagger.hilt.EntryPoint
|
import dagger.hilt.EntryPoint
|
||||||
import dagger.hilt.InstallIn
|
import dagger.hilt.InstallIn
|
||||||
import dagger.hilt.android.EntryPointAccessors
|
import dagger.hilt.android.EntryPointAccessors
|
||||||
@@ -17,6 +19,7 @@ import de.jeanlucmakiola.floret.crash.CrashReporter
|
|||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import java.util.concurrent.atomic.AtomicBoolean
|
import java.util.concurrent.atomic.AtomicBoolean
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Application entry point. Registered as android:name=".AgendulaApp". Besides
|
* Application entry point. Registered as android:name=".AgendulaApp". Besides
|
||||||
@@ -24,7 +27,20 @@ import java.util.concurrent.atomic.AtomicBoolean
|
|||||||
* so alarms reflect tasks synced while the app was closed.
|
* so alarms reflect tasks synced while the app was closed.
|
||||||
*/
|
*/
|
||||||
@HiltAndroidApp
|
@HiltAndroidApp
|
||||||
class AgendulaApp : Application() {
|
class AgendulaApp : Application(), Configuration.Provider {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lets `@HiltWorker` workers take injected dependencies. The manifest removes
|
||||||
|
* WorkManager's default initializer so this one is used instead; without both
|
||||||
|
* halves, a worker with a constructor argument fails to instantiate at
|
||||||
|
* runtime rather than at build time.
|
||||||
|
*/
|
||||||
|
@Inject lateinit var workerFactory: HiltWorkerFactory
|
||||||
|
|
||||||
|
override val workManagerConfiguration: Configuration
|
||||||
|
get() = Configuration.Builder()
|
||||||
|
.setWorkerFactory(workerFactory)
|
||||||
|
.build()
|
||||||
|
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
|
|||||||
@@ -33,6 +33,17 @@ private val Context.agendulaDataStore: DataStore<Preferences> by preferencesData
|
|||||||
name = "agendula_prefs",
|
name = "agendula_prefs",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/** See [CredentialsDataStore] for why this is a separate file. */
|
||||||
|
private val Context.credentialsDataStore: DataStore<Preferences> 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
|
@Module
|
||||||
@InstallIn(SingletonComponent::class)
|
@InstallIn(SingletonComponent::class)
|
||||||
abstract class DataBindModule {
|
abstract class DataBindModule {
|
||||||
@@ -55,6 +66,12 @@ object DataProvideModule {
|
|||||||
fun provideDataStore(@ApplicationContext context: Context): DataStore<Preferences> =
|
fun provideDataStore(@ApplicationContext context: Context): DataStore<Preferences> =
|
||||||
context.agendulaDataStore
|
context.agendulaDataStore
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
@CredentialsDataStore
|
||||||
|
fun provideCredentialsDataStore(@ApplicationContext context: Context): DataStore<Preferences> =
|
||||||
|
context.credentialsDataStore
|
||||||
|
|
||||||
@Provides
|
@Provides
|
||||||
@Singleton
|
@Singleton
|
||||||
fun provideTasksDatabase(@ApplicationContext context: Context): TasksDatabase =
|
fun provideTasksDatabase(@ApplicationContext context: Context): TasksDatabase =
|
||||||
|
|||||||
@@ -16,3 +16,17 @@ annotation class IoDispatcher
|
|||||||
@Qualifier
|
@Qualifier
|
||||||
@Retention(AnnotationRetention.BINARY)
|
@Retention(AnnotationRetention.BINARY)
|
||||||
annotation class ApplicationScope
|
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
|
||||||
|
|||||||
@@ -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<Account> =
|
||||||
|
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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<Preferences>,
|
||||||
|
) {
|
||||||
|
|
||||||
|
/** 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<SyncWorker>()
|
||||||
|
.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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<out String>?,
|
||||||
|
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<out String>?,
|
||||||
|
): 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
|
||||||
|
}
|
||||||
@@ -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 `<sync-adapter android:contentAuthority>` 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<out String>?,
|
||||||
|
selection: String?,
|
||||||
|
selectionArgs: Array<out String>?,
|
||||||
|
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<out String>?) = 0
|
||||||
|
|
||||||
|
override fun update(
|
||||||
|
uri: Uri,
|
||||||
|
values: ContentValues?,
|
||||||
|
selection: String?,
|
||||||
|
selectionArgs: Array<out String>?,
|
||||||
|
) = 0
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!--
|
||||||
|
The account type is generated per build variant by `resValue` in
|
||||||
|
app/build.gradle.kts, so the debug and releaseTest builds get their own and
|
||||||
|
can sit alongside the real app without their accounts colliding. It must stay
|
||||||
|
in step with SyncContract.ACCOUNT_TYPE.
|
||||||
|
-->
|
||||||
|
<account-authenticator xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:accountType="@string/account_type"
|
||||||
|
android:icon="@mipmap/ic_launcher"
|
||||||
|
android:smallIcon="@mipmap/ic_launcher"
|
||||||
|
android:label="@string/app_name" />
|
||||||
@@ -19,4 +19,15 @@
|
|||||||
<include domain="database" path="agendula-tasks.db-wal" />
|
<include domain="database" path="agendula-tasks.db-wal" />
|
||||||
<include domain="database" path="agendula-tasks.db-shm" />
|
<include domain="database" path="agendula-tasks.db-shm" />
|
||||||
<include domain="file" path="datastore/" />
|
<include domain="file" path="datastore/" />
|
||||||
|
<!--
|
||||||
|
The one thing that must not travel. Keystore keys are non-exportable, so
|
||||||
|
a restored ciphertext can never be decrypted again — it would surface as
|
||||||
|
an account that silently stops syncing with no way to tell why. Excluding
|
||||||
|
it means the user signs in again on a new device, which is the honest
|
||||||
|
outcome. Note this is the *only* exclusion that is right here:
|
||||||
|
docs/SYNC.md is explicit that dropping the database or all of DataStore
|
||||||
|
from backup would trade a latent bug for a live one, since Auto Backup is
|
||||||
|
Local mode's only automatic safety net.
|
||||||
|
-->
|
||||||
|
<exclude domain="file" path="datastore/agendula_credentials.preferences_pb" />
|
||||||
</full-backup-content>
|
</full-backup-content>
|
||||||
|
|||||||
@@ -11,11 +11,14 @@
|
|||||||
<include domain="database" path="agendula-tasks.db-wal" />
|
<include domain="database" path="agendula-tasks.db-wal" />
|
||||||
<include domain="database" path="agendula-tasks.db-shm" />
|
<include domain="database" path="agendula-tasks.db-shm" />
|
||||||
<include domain="file" path="datastore/" />
|
<include domain="file" path="datastore/" />
|
||||||
|
<!-- See backup_rules.xml: a restored ciphertext is undecryptable. -->
|
||||||
|
<exclude domain="file" path="datastore/agendula_credentials.preferences_pb" />
|
||||||
</cloud-backup>
|
</cloud-backup>
|
||||||
<device-transfer>
|
<device-transfer>
|
||||||
<include domain="database" path="agendula-tasks.db" />
|
<include domain="database" path="agendula-tasks.db" />
|
||||||
<include domain="database" path="agendula-tasks.db-wal" />
|
<include domain="database" path="agendula-tasks.db-wal" />
|
||||||
<include domain="database" path="agendula-tasks.db-shm" />
|
<include domain="database" path="agendula-tasks.db-shm" />
|
||||||
<include domain="file" path="datastore/" />
|
<include domain="file" path="datastore/" />
|
||||||
|
<exclude domain="file" path="datastore/agendula_credentials.preferences_pb" />
|
||||||
</device-transfer>
|
</device-transfer>
|
||||||
</data-extraction-rules>
|
</data-extraction-rules>
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!--
|
||||||
|
Since Android 7 a user who correctly installs their private CA into the
|
||||||
|
system store is *still* not trusted by apps — user CAs are excluded from the
|
||||||
|
default trust anchors. That breaks exactly the self-hosting audience this app
|
||||||
|
is for, so they are added back here.
|
||||||
|
|
||||||
|
Cleartext stays off. It has been the default since API 28, and CalDavDiscovery
|
||||||
|
refuses a typed http:// address for the same reason: credentials are never
|
||||||
|
sent over an unencrypted connection. Any escape hatch has to be a narrow,
|
||||||
|
warned, per-account opt-in — Play's User Data policy requires modern
|
||||||
|
cryptography in transit — and this file is not the place for it.
|
||||||
|
|
||||||
|
⚠️ KNOWN TRADE-OFF, and it is the widest form of this. base-config applies to
|
||||||
|
*all* traffic, so any CA in the user store — a corporate MDM profile, a "free
|
||||||
|
VPN" app's certificate, one installed during some earlier debugging — can
|
||||||
|
transparently intercept the CalDAV connection and read the app password out
|
||||||
|
of the Authorization header. docs/SYNC.md specifies this file, and without it
|
||||||
|
a correctly installed private CA is simply not trusted, which is the
|
||||||
|
self-hosting case the app exists to serve.
|
||||||
|
|
||||||
|
The narrower posture is cert4android's: trust nothing extra by default, and
|
||||||
|
ask the user per connection via its bound service + notification. That is
|
||||||
|
SYNC-PLAN.md chunk 5, and it is what should replace this block rather than
|
||||||
|
sit alongside it.
|
||||||
|
-->
|
||||||
|
<network-security-config>
|
||||||
|
<base-config cleartextTrafficPermitted="false">
|
||||||
|
<trust-anchors>
|
||||||
|
<certificates src="system" />
|
||||||
|
<certificates src="user" />
|
||||||
|
</trust-anchors>
|
||||||
|
</base-config>
|
||||||
|
</network-security-config>
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!--
|
||||||
|
userVisible="false" keeps our authority out of the per-item sync list in
|
||||||
|
system Settings — there is only one thing to sync and a switch for it adds
|
||||||
|
nothing. The cost is that Settings' "Sync now" stays greyed out, because
|
||||||
|
enabledSyncNowMenu() needs at least one checked authority switch, which is
|
||||||
|
why the app ships its own sync button.
|
||||||
|
|
||||||
|
supportsUploading="true": this is a two-way sync, and declaring otherwise
|
||||||
|
stops the framework requesting a sync on local changes.
|
||||||
|
-->
|
||||||
|
<sync-adapter xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:contentAuthority="@string/sync_authority"
|
||||||
|
android:accountType="@string/account_type"
|
||||||
|
android:userVisible="false"
|
||||||
|
android:supportsUploading="true"
|
||||||
|
android:allowParallelSyncs="false"
|
||||||
|
android:isAlwaysSyncable="true" />
|
||||||
+50
-1
@@ -58,7 +58,8 @@ Nextcloud/Radicale/Baïkal in chunk 5.
|
|||||||
| 1 | **Mapper** — VTODO ↔ Room | nothing | Unknown properties survive a read-modify-write cycle |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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
|
## 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 `<sync-adapter android:contentAuthority>` 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
|
**Goal:** the app can add a CalDAV account and list its VTODO collections. No
|
||||||
syncing yet.
|
syncing yet.
|
||||||
|
|
||||||
|
|||||||
@@ -26,7 +26,12 @@ espressoCore = "3.7.0"
|
|||||||
kotlinxDatetime = "0.7.0"
|
kotlinxDatetime = "0.7.0"
|
||||||
kotlinxCoroutines = "1.10.2"
|
kotlinxCoroutines = "1.10.2"
|
||||||
turbine = "1.2.0"
|
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"
|
navigationCompose = "2.9.0"
|
||||||
lifecycleCompose = "2.10.0"
|
lifecycleCompose = "2.10.0"
|
||||||
androidxTestRules = "1.7.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" }
|
turbine = { group = "app.cash.turbine", name = "turbine", version.ref = "turbine" }
|
||||||
|
|
||||||
# Hilt navigation-compose (for hiltViewModel() in Composables)
|
# 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)
|
# Navigation-compose (the NavHost / back stack)
|
||||||
androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" }
|
androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" }
|
||||||
|
|||||||
Reference in New Issue
Block a user