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:
2026-09-04 18:00:07 +02:00
parent da42423fe2
commit ec50e0998c
20 changed files with 1026 additions and 4 deletions
+12
View File
@@ -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" />
+11
View File
@@ -19,4 +19,15 @@
<include domain="database" path="agendula-tasks.db-wal" />
<include domain="database" path="agendula-tasks.db-shm" />
<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>
@@ -11,11 +11,14 @@
<include domain="database" path="agendula-tasks.db-wal" />
<include domain="database" path="agendula-tasks.db-shm" />
<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>
<device-transfer>
<include domain="database" path="agendula-tasks.db" />
<include domain="database" path="agendula-tasks.db-wal" />
<include domain="database" path="agendula-tasks.db-shm" />
<include domain="file" path="datastore/" />
<exclude domain="file" path="datastore/agendula_credentials.preferences_pb" />
</device-transfer>
</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>
+18
View File
@@ -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" />