feat: offline flavor without sync or network access (#39)

This commit is contained in:
2026-10-06 18:34:33 +02:00
parent 7fde7ae290
commit 84bb1116ec
107 changed files with 779 additions and 433 deletions
+88
View File
@@ -0,0 +1,88 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- CalDAV sync and UnifiedPush; everything here needs the network. -->
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- 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" />
<queries>
<!-- Custom Tabs provider detection. Without this entry it silently finds
nothing on API 30+, and the Nextcloud login flow falls back to an
external browser for no visible reason. -->
<intent>
<action android:name="android.support.customtabs.action.CustomTabsService" />
</intent>
</queries>
<application android:networkSecurityConfig="@xml/network_security_config">
<!-- 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>
<!-- UnifiedPush: the connector binds this to deliver endpoints and
WebDAV-Push messages. Not exported; the connector's own receiver is
what distributors talk to. -->
<service
android:name=".data.sync.push.AgendulaPushService"
android:exported="false">
<intent-filter>
<action android:name="org.unifiedpush.android.connector.PUSH_EVENT" />
</intent-filter>
</service>
</application>
</manifest>
@@ -0,0 +1,131 @@
package de.jeanlucmakiola.agendula.data.di
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.preferencesDataStore
import dagger.Binds
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import dagger.multibindings.IntoSet
import de.jeanlucmakiola.agendula.data.sync.AccountCreator
import de.jeanlucmakiola.agendula.data.sync.AccountRepository
import de.jeanlucmakiola.agendula.data.sync.CalDavGateway
import de.jeanlucmakiola.agendula.data.sync.LoginFlowRecord
import de.jeanlucmakiola.agendula.data.sync.OkHttpCalDavGateway
import de.jeanlucmakiola.agendula.data.sync.PendingLoginFlowStore
import de.jeanlucmakiola.agendula.data.sync.RemoteListRepository
import de.jeanlucmakiola.agendula.data.sync.RemoteLists
import de.jeanlucmakiola.agendula.data.sync.SyncNoticeNotifier
import de.jeanlucmakiola.agendula.data.sync.SyncOnEdit
import de.jeanlucmakiola.agendula.data.sync.SyncRequests
import de.jeanlucmakiola.agendula.data.sync.SyncTrigger
import de.jeanlucmakiola.agendula.data.sync.push.PushRegistrar
import de.jeanlucmakiola.agendula.data.tasks.room.LocalWriteListener
import javax.inject.Provider
import javax.inject.Singleton
/** See [CredentialsDataStore] for why this is a separate file. */
private val Context.credentialsDataStore: DataStore<Preferences> by preferencesDataStore(
name = CREDENTIALS_DATASTORE,
corruptionHandler = replaceCorrupted(),
)
/** See [SyncStateDataStore] for why this is a separate file. */
private val Context.syncStateDataStore: DataStore<Preferences> by preferencesDataStore(
name = SYNC_STATE_DATASTORE,
corruptionHandler = replaceCorrupted(),
)
/**
* 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"
/**
* Named here and in `backup_rules.xml` / `data_extraction_rules.xml`, which
* exclude `datastore/$SYNC_STATE_DATASTORE.preferences_pb` by this name.
*/
const val SYNC_STATE_DATASTORE = "agendula_sync_state"
/** CalDAV sync: the `full` flavor's side of the seams `main` declares. */
@Module
@InstallIn(SingletonComponent::class)
abstract class SyncBindModule {
@Binds
@Singleton
abstract fun bindCalDavGateway(impl: OkHttpCalDavGateway): CalDavGateway
@Binds
@Singleton
abstract fun bindAccountCreator(impl: AccountRepository): AccountCreator
@Binds
@Singleton
abstract fun bindLoginFlowRecord(impl: PendingLoginFlowStore): LoginFlowRecord
@Binds
@Singleton
abstract fun bindLocalWriteListener(impl: SyncOnEdit): LocalWriteListener
@Binds
@Singleton
abstract fun bindRemoteLists(impl: RemoteListRepository): RemoteLists
@Binds
@Singleton
abstract fun bindSyncRequests(impl: SyncTrigger): SyncRequests
@Binds
@IntoSet
abstract fun bindSyncNoticeChannel(impl: SyncNoticeNotifier): ChannelRefresher
}
@Module
@InstallIn(SingletonComponent::class)
object SyncProvideModule {
@Provides
@Singleton
@CredentialsDataStore
fun provideCredentialsDataStore(@ApplicationContext context: Context): DataStore<Preferences> =
context.credentialsDataStore
@Provides
@Singleton
@SyncStateDataStore
fun provideSyncStateDataStore(@ApplicationContext context: Context): DataStore<Preferences> =
context.syncStateDataStore
/**
* Sync hard on app open: the periodic worker's interval is a floor, and in
* the `rare` and `restricted` App Standby buckets it may not have run at
* all. `KEEP` makes rescheduling idempotent, so this also repairs a schedule
* lost to "clear app data" or to a restore.
*/
@Provides
@IntoSet
fun syncOnOpenHook(
accounts: Provider<AccountRepository>,
syncTrigger: Provider<SyncTrigger>,
pendingLoginFlows: Provider<PendingLoginFlowStore>,
push: Provider<PushRegistrar>,
): LaunchHook = LaunchHook {
runCatching {
accounts.get().rescheduleAll()
accounts.get().syncable().forEach { syncTrigger.get().enqueue(it.displayName) }
// A login flow the previous process died in the middle of.
// Its password, if the user approved, exists nowhere else.
pendingLoginFlows.get().reclaim()
}
// Last and on its own: it waits on the network, and must not hold up
// the reclaim above. Re-registering on open is what the connector
// recommends.
runCatching { push.get().updateAll() }
}
}
@@ -0,0 +1,34 @@
package de.jeanlucmakiola.agendula.data.di
import javax.inject.Qualifier
/**
* 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 — excluding the whole database or
* all of DataStore would trade a latent bug for a live one.
*/
@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class CredentialsDataStore
/**
* Marks the DataStore holding per-device **sync bookkeeping** — the quarantine
* counters and the full-reconciliation clock.
*
* Its own file for the same reason the credentials have one: Auto Backup
* includes `datastore/`, and every value in here is a statement about *this*
* device's conversation with a server. Restored onto a new install they are all
* lies, and two of them are dangerous — a restored "reconciled recently" makes
* the engine trust a sync token for another day, which is precisely the silently
* pruned change log the full path exists to catch, and a restored quarantine
* count silently skips resources that were never tried here.
*
* Not user data, so nothing is lost by excluding it.
*/
@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class SyncStateDataStore
@@ -0,0 +1,604 @@
package de.jeanlucmakiola.agendula.data.sync
import de.jeanlucmakiola.agendula.data.di.IoDispatcher
import de.jeanlucmakiola.agendula.data.sync.push.PushRegistrar
import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.agendula.data.tasks.room.AccountEntity
import de.jeanlucmakiola.agendula.data.tasks.room.TaskListEntity
import de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase
import de.jeanlucmakiola.caldav.CalDavDiscovery
import de.jeanlucmakiola.caldav.TaskCollection
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withContext
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import javax.inject.Inject
import javax.inject.Singleton
/**
* The one thing the sign-in flow needs from [AccountRepository].
*
* A seam, so the flow's state machine can be tested without a database, a
* Keystore or an `AccountManager` — the three things that make the rest of this
* class Android-only.
*/
interface AccountCreator {
suspend fun create(
displayName: String,
username: String,
appPassword: String,
found: CalDavDiscovery.Outcome.Found,
selected: Set<TaskCollection>,
/** An existing account to sign in again, instead of creating one. */
reauthenticating: Long? = null,
): AccountRepository.Outcome
}
/**
* Turns a finished sign-in into an account that exists in all three places it
* has to: the Room `accounts` row, the encrypted credential, and the
* system-visible `AccountManager` entry.
*
* The order matters. The Room row comes first because its id keys the
* credential, and the system account comes last because it is the one thing a
* user can see — an entry in Settings for an account whose credential failed to
* store would be a sync that silently never works.
*/
@Singleton
class AccountRepository @Inject constructor(
private val database: TasksDatabase,
private val credentials: CredentialStore,
private val accounts: CalDavAccounts,
private val syncTrigger: SyncTrigger,
private val cadence: SyncCadenceStore,
private val accountState: AccountStateStore,
private val quarantine: QuarantineStore,
private val notices: SyncNoticeStore,
private val collectionSupport: CollectionSupportStore,
private val gateway: CalDavGateway,
private val availability: SyncAvailability,
private val settings: SettingsPrefs,
private val push: PushRegistrar,
@IoDispatcher private val io: CoroutineDispatcher,
) : AccountCreator {
private suspend fun syncInterval(): Int = settings.settings.first().syncIntervalMinutes
/** What went wrong, in words a user can act on. */
sealed interface Outcome {
data class Created(val accountId: Long) : Outcome
data object AlreadyExists : Outcome
/** External storage mode is on, and nothing would ever show what this account syncs. */
data object ExternalStorage : Outcome
data class CredentialFailed(val cause: Cause, val detail: String = "") : Outcome
/**
* Why an account could not be saved, in a form the UI can translate.
*
* ⚠️ The UI renders *this*, never [CredentialFailed.detail] — which is
* a `Throwable.message` and so an untranslated, often unreadable string.
*/
enum class Cause {
/** The Keystore refused to hold the password. */
KEYSTORE_REFUSED,
/** Anything else that stopped the write. */
NOT_SAVED,
}
}
suspend fun all(): List<AccountEntity> = withContext(io) { database.accounts().all() }
/** The accounts, observed, so a sync landing updates a screen that is open. */
fun observeAll(): Flow<List<AccountEntity>> = database.accounts().observeAll()
/** Every account's synced lists. */
fun observeSyncedLists(): Flow<List<TaskListEntity>> = database.taskLists().observeSynced()
/**
* Creates an account and the task lists the user chose.
*
* [selected] is a subset of what discovery found; a collection the user did
* not tick is simply not created, and can be added later without touching
* anything else — `task_lists.account_id` is a nullable FK, so attaching is
* an `UPDATE`.
*/
override suspend fun create(
displayName: String,
username: String,
appPassword: String,
found: CalDavDiscovery.Outcome.Found,
selected: Set<TaskCollection>,
reauthenticating: Long?,
): Outcome = withContext(io) {
if (!availability.accountsUsable()) return@withContext Outcome.ExternalStorage
// "Sign in again" names its account, so a login name the server spells
// differently from last time still lands on it rather than beside it.
// Only on the same server: anything else is a different account.
val target = reauthenticating?.let { database.accounts().account(it) }
if (target != null && sameServer(target, found)) {
return@withContext reauthenticate(target, target.displayName, username, appPassword, found, selected)
}
// Both stores, not just one. There is no unique index on
// accounts.display_name and nothing prunes Room when the system account
// disappears, so "removed from system Settings, re-added here" would
// otherwise leave a second Room row and a duplicate of every list.
val systemAccount = accounts.find(displayName)
val existing = database.accounts().all().firstOrNull { it.displayName == displayName }
// ⚠️ Re-authentication, not a duplicate. An account stopped by a 401 has
// no other way back: `create` is the only path that writes a credential,
// and refusing it here left the user with "that account is already set
// up" and no option but to remove the account — discarding the choice to
// keep its lists attached. Narrow on purpose: a *healthy* account of the
// same name is still a duplicate, so this can never silently overwrite a
// working credential.
if (existing != null && accountState.needsSignIn(existing.id)) {
return@withContext reauthenticate(existing, displayName, username, appPassword, found, selected)
}
if (existing != null) return@withContext Outcome.AlreadyExists
// ⚠️ In the system, not in Room: an orphan, not a duplicate. `remove()`
// ignores whether the AccountManager entry actually went (and `find`
// returns null while the device is locked), so this state is reachable —
// and refusing here left the user with an account that cannot be removed
// from inside the app at all, since the accounts screen is driven off
// Room. Clearing it up is kinder than refusing for ever.
systemAccount?.let { accounts.remove(it) }
// ⚠️ Uncancellable as a whole. The row, its lists, the credential and
// the system entry are four stores that cannot share a transaction, and
// the caller is a viewModelScope tied to the Settings destination — a
// back gesture during "Adding the account" would otherwise leave the row
// and its lists with no credential and no system account: the rollback
// never runs, `needsSignIn` is false so re-auth will not fire, and every
// retry answers AlreadyExists. `remove()` documents the same hazard.
withContext(NonCancellable) {
val inserted = mutableListOf<Long>()
val accountId = database.runInTransaction<Long> {
val id = database.accounts().insert(
AccountEntity(
displayName = displayName,
// Persist where a 301/308 actually put us — dav4jvm#209 exists
// precisely so this is knowable, and re-following the redirect
// on every sync is what not persisting it costs.
principalUrl = (found.movedTo ?: found.principal).toString(),
// The principal's own home set. `resolve("./")` on a collection
// URL is a no-op — CalDAV hrefs already end in "/" — so the
// old version stored the first collection's own URL, and that
// collection may not even be from the account's own home set.
homeSetUrl = found.homeSets.firstOrNull()?.toString(),
username = username,
),
)
inserted += attach(id, selected)
id
}
if (!credentials.put(accountId, appPassword)) {
// Never leave a half-made account behind: without a credential it
// would sit in Settings failing to sync with nothing to explain it.
rollback(accountId, inserted)
return@withContext Outcome.CredentialFailed(
Outcome.Cause.KEYSTORE_REFUSED,
"the device keystore would not store the password",
)
}
if (!accounts.add(displayName, accountId)) {
credentials.clear(accountId)
rollback(accountId, inserted)
return@withContext Outcome.AlreadyExists
}
// On the schedule from the moment it exists, and syncing immediately —
// an account that shows up empty until the first periodic window looks
// broken.
syncTrigger.schedule(displayName, syncInterval())
syncTrigger.enqueue(displayName)
Outcome.Created(accountId)
}
}
/**
* Points [selected] at [accountId], re-attaching what a previous removal
* left behind rather than inserting a second copy.
*
* ⚠️ `remove()` leaves the lists as device-only ones on purpose, so a plain
* insert gives the user their old "Personal" full of tasks *and* a freshly
* synced "Personal" holding the same tasks from the server. The row keeps
* its name, colour, ordering and tasks — the user's, not the server's — and
* loses only its cursor, because the account it was reconciled against is
* gone.
*
* ⚠️ On a re-authentication this also has to see the account's *own* lists,
* not just detached ones — the re-auth path walks the same picker, so a
* lookup that missed them would insert a second copy of every list the
* account already syncs. On a create the id was minted a statement earlier,
* so nothing is attached to it yet and only detached rows can match.
*
* @return the ids of the lists this call *created*, which are the only ones
* a rollback may delete.
*/
private fun attach(accountId: Long, selected: Set<TaskCollection>): List<Long> {
val known = database.taskLists().attachable(accountId).associateBy { it.href }
val inserted = mutableListOf<Long>()
selected.forEach { collection ->
val href = collection.url.toString()
val current = known[href]
if (current == null) {
inserted += database.taskLists().insert(
TaskListEntity(
name = collection.displayName ?: collection.url.pathSegments
.lastOrNull { it.isNotEmpty() }
.orEmpty(),
color = collection.color ?: DEFAULT_LIST_COLOR,
accountId = accountId,
isReadOnly = collection.readOnly,
href = href,
),
)
} else {
// Changing hands, as opposed to re-authenticating the account
// that already owns it: a cursor from the previous owner says
// nothing about this one, while the current owner's is still
// good and throwing it away costs a full reconciliation.
val changingHands = current.accountId != accountId
database.taskLists().update(
current.copy(
accountId = accountId,
isReadOnly = collection.readOnly,
syncToken = current.syncToken.takeUnless { changingHands },
ctag = current.ctag.takeUnless { changingHands },
),
)
}
}
return inserted
}
/** What the server offers an existing account, beside what it already syncs. */
sealed interface Collections {
data class Found(
val collections: List<TaskCollection>,
/** Hrefs of the collections this account already syncs. */
val attached: Set<String>,
) : Collections
/** The account is stopped, or has no credential we can use. */
data object NeedsSignIn : Collections
data class Failed(val cause: CalDavDiscovery.Outcome.Cause?) : Collections
}
/**
* Re-runs discovery for [accountId], so lists added on the server after
* setup can be picked up and ones that are synced can be dropped.
*/
suspend fun collections(accountId: Long): Collections = withContext(io) {
if (accountState.needsSignIn(accountId)) return@withContext Collections.NeedsSignIn
val account = database.accounts().account(accountId)
?: return@withContext Collections.Failed(null)
val username = account.username ?: return@withContext Collections.NeedsSignIn
val principal = account.principalUrl?.toHttpUrlOrNull()
?: return@withContext Collections.Failed(null)
val password = (credentials.get(accountId) as? CredentialStore.Secret.Present)?.value
?: return@withContext Collections.NeedsSignIn
val attached = database.taskLists().syncedForAccount(accountId)
.mapNotNull { it.href }
.toSet()
when (
val outcome = gateway.discover(
principal.toString(),
CalDavGateway.Credentials(username, password, principal),
)
) {
is CalDavDiscovery.Outcome.Found -> Collections.Found(outcome.collections, attached)
is CalDavDiscovery.Outcome.NeedsAuthentication,
CalDavDiscovery.Outcome.Unauthenticated,
-> Collections.NeedsSignIn
is CalDavDiscovery.Outcome.NotCalDav -> Collections.Failed(outcome.cause)
is CalDavDiscovery.Outcome.Failed -> Collections.Failed(outcome.cause)
}
}
/**
* Makes [selected] the synced subset of [offered] for [accountId].
*
* Newly ticked collections are attached and synced. Unticked ones that were
* synced go the way an account removal takes its lists: detached as
* device-only lists when [keepUnticked], deleted from this device otherwise.
* Nothing is deleted on the server either way.
*/
suspend fun setSyncedCollections(
accountId: Long,
offered: List<TaskCollection>,
selected: Set<HttpUrl>,
keepUnticked: Boolean,
) = withContext(io) {
val account = database.accounts().account(accountId) ?: return@withContext
withContext(NonCancellable) {
val change = collectionChange(
offered,
selected,
database.taskLists().syncedForAccount(accountId),
)
database.runInTransaction {
attach(accountId, change.attach)
change.drop.forEach { list ->
if (keepUnticked) {
database.taskLists().setAccount(list.id, null)
} else {
database.taskLists().delete(list.id)
}
}
}
val dropped = change.drop.map { it.id }.toSet()
runCatching { push.forgetLists(accountId, dropped) }
cadence.forget(dropped)
quarantine.forget(dropped)
change.drop.forEach { notices.forgetList(accountId, it.name) }
if (change.attach.isNotEmpty()) syncTrigger.enqueue(account.displayName, expedited = true)
}
}
/**
* Gives a quarantined resource another go: its failure count is cleared and
* the account synced. If it fails again it is counted afresh, and the notice
* comes back once it reaches the threshold again.
*/
suspend fun retryQuarantined(accountId: Long, key: String) = withContext(io) {
val account = database.accounts().account(accountId) ?: return@withContext
val listIds = database.taskLists().syncedForAccount(accountId).map { it.id }.toSet()
quarantine.release(listIds, key)
notices.forgetQuarantined(accountId, key)
syncTrigger.enqueue(account.displayName, expedited = true)
}
/**
* Replaces the credential of an account the server had stopped accepting.
*
* The tasks stay exactly as they are — the password was the only thing that
* went stale. Everything the user was asked for on the way here is applied
* all the same:
*
* ⚠️ This branch used to take [appPassword] and drop [found] and [selected]
* on the floor. The user walked the whole add flow, ticked collections, and
* was shown Done — while no newly-ticked list was created, no unticked one
* was detached, and a moved principal or a corrected username was discarded,
* so a relocated account could never be repaired. It also never called
* `accounts.add`, so an account the user had deleted in Android Settings —
* nothing listens for `LOGIN_ACCOUNTS_CHANGED` — stayed absent from Settings
* for ever while syncing happily via WorkManager, and every later add
* answered AlreadyExists.
*/
private suspend fun reauthenticate(
existing: AccountEntity,
displayName: String,
username: String,
appPassword: String,
found: CalDavDiscovery.Outcome.Found,
selected: Set<TaskCollection>,
): Outcome {
val accountId = existing.id
if (!credentials.put(accountId, appPassword)) {
return Outcome.CredentialFailed(
Outcome.Cause.KEYSTORE_REFUSED,
"the device keystore would not store the password",
)
}
withContext(NonCancellable) {
database.runInTransaction {
database.accounts().update(
existing.copy(
// Where discovery just found it, which is the only way a
// principal that has moved can ever be corrected.
principalUrl = (found.movedTo ?: found.principal).toString(),
homeSetUrl = found.homeSets.firstOrNull()?.toString()
?: existing.homeSetUrl,
username = username,
),
)
attach(accountId, selected)
}
// ⚠️ Ticked lists are attached; unticked ones are left alone. The
// picker pre-ticks everything *writable*, not everything already
// attached, so detaching what is unticked would silently stop
// syncing a read-only share the account has synced for months —
// over a default the user never chose. Detaching belongs here the
// day the picker knows what this account already holds.
// The Room row is the account as far as this app is concerned, so a
// missing system entry is re-registered rather than left behind.
if (accounts.find(displayName) == null) accounts.add(displayName, accountId)
accountState.setNeedsSignIn(accountId, false)
database.accounts().recordSync(accountId, at = null, error = null)
syncTrigger.schedule(displayName, syncInterval())
syncTrigger.enqueue(displayName)
}
return Outcome.Created(accountId)
}
/**
* Undoes a half-made account.
*
* ⚠️ The lists this attempt *created* have to go explicitly.
* `task_lists.account_id` is `ON DELETE SET NULL` — deliberately, so
* removing a working account never destroys tasks — which means deleting the
* account row alone would leave a set of empty device-only lists behind.
*
* ⚠️ And only those. A list [attach] re-attached was already on the device
* and holds the user's tasks; `SET NULL` returns it to being device-only,
* which is exactly where it came from.
*/
private fun rollback(accountId: Long, inserted: List<Long>) = database.runInTransaction {
inserted.forEach { database.taskLists().delete(it) }
database.accounts().delete(accountId)
}
/**
* Puts every existing account back on the periodic schedule.
*
* Cheap and idempotent — `KEEP` means an already-scheduled account is left
* exactly as it is — so calling it on app open costs nothing and repairs the
* one case WorkManager cannot: a schedule lost to "clear app data" or to a
* restore onto a device that never ran the account-add flow.
*/
suspend fun rescheduleAll(intervalChanged: Boolean = false) = withContext(io) {
val stopped = accountState.needingSignIn()
database.accounts().all().forEach { account ->
// ⚠️ A stopped account must not come back on the timer. `KEEP` only
// keeps work that is unfinished, and CANCELLED counts as finished —
// so rescheduling would re-enqueue the very request a 401 removed,
// and the next app open would put a dead app password back on a
// four-hour loop against a server that throttles by IP.
//
// This is also where the cancellation happens at all: the engine
// cannot cancel from inside the worker it is running in.
if (account.id in stopped) {
syncTrigger.cancel(account.displayName)
} else {
syncTrigger.schedule(account.displayName, syncInterval(), intervalChanged)
}
}
}
/** The accounts a caller may sync right now — stopped ones excluded. */
suspend fun syncable(): List<AccountEntity> = withContext(io) {
val stopped = accountState.needingSignIn()
database.accounts().all().filterNot { it.id in stopped }
}
/**
* Removes an account and everything that keys off it.
*
* The lists are **not** deleted: `task_lists.account_id` is `ON DELETE SET
* NULL`, so they become device-only lists. Removing an account is not an
* instruction to destroy the tasks it held.
*/
suspend fun remove(accountId: Long, displayName: String, deleteLocalData: Boolean = false) =
withContext(io) {
syncTrigger.cancel(displayName)
// Before the revocation: the subscriptions can only be removed with
// the credential that is about to stop working.
withContext(NonCancellable) { runCatching { push.forgetAccount(accountId) } }
revokeAppPassword(accountId)
// ⚠️ Uncancellable from here. Everything below is destructive and
// spread over four stores that cannot share a transaction, and the
// caller is a viewModelScope tied to the Settings destination — the
// user taps Remove, the screen slides away, and a couple of back
// gestures kill the scope mid-sequence. Only the DataStore writes can
// observe cancellation (every Room DAO here is blocking), so the
// realistic landing point is `cadence.forget`: the app password is
// already revoked server-side while the row survives holding it, and
// the account reads "sign in again" for a credential we ourselves
// invalidated. Land further in and the tasks are gone with the row
// still there. The tail is three DataStore writes, two deletes and an
// AccountManager call — bounded and sub-second, so finishing it is
// strictly better than stopping anywhere inside it.
withContext(NonCancellable) {
// The lists survive as device-only lists, so their cursors must
// not: a re-added account would otherwise inherit a "reconciled
// recently" that was true of a different account's data.
val listIds = database.taskLists().syncedForAccount(accountId)
.map { it.id }
.toSet()
cadence.forget(listIds)
// ⚠️ And the quarantine counts, which are keyed the same way and
// are just as global. A list re-attached to a new account would
// otherwise inherit them, and a resource already at THRESHOLD is
// skipped for ever — it never succeeds, so it never clears.
quarantine.forget(listIds)
// Play's Account Deletion policy does not apply to us — there is
// no Agendula account to delete — but "I want it gone from this
// device too" is a reasonable thing to want, and it is the only
// way to get the tasks off the device without also uninstalling.
if (deleteLocalData) database.taskLists().deleteForAccount(accountId)
accountState.setNeedsSignIn(accountId, false)
// Keyed by account id, exactly like the flag above, and just as
// orphaned once the row goes: ids are AUTOINCREMENT so they are
// never reused, but nothing would ever read or clear these again.
notices.dismiss(accountId)
// The same reasoning, for what the server said it would let us
// create: keyed by an id nothing will ever mention again.
collectionSupport.forget(accountId)
credentials.clear(accountId)
database.accounts().delete(accountId)
accounts.find(displayName)?.let { accounts.remove(it) }
}
}
/**
* Best effort, and before the credential is cleared — it is the credential.
*
* A failure here is never allowed to stop the removal: the user asked for the
* account to go, and a server that is unreachable, or was never a Nextcloud,
* is not a reason to keep it.
*/
private suspend fun revokeAppPassword(accountId: Long) {
val account = database.accounts().account(accountId) ?: return
val username = account.username ?: return
val origin = account.principalUrl?.toHttpUrlOrNull() ?: return
val password = (credentials.get(accountId) as? CredentialStore.Secret.Present)?.value
?: return
// ⚠️ The budget lives on the request itself, in AppPassword.revoke.
// Wrapping this in withTimeoutOrNull only *looked* bounded: the call
// parks on a socket read that no cancellation can break, and withContext
// returns when its block does, so the deadline passed and we waited
// anyway — minutes, on a multi-homed host that stalls.
try {
gateway.revokeAppPassword(
CalDavGateway.Credentials(username, password, origin),
)
} catch (_: CancellationException) {
// Deliberately swallowed. If the caller went away mid-revoke we still
// want the removal to finish rather than stop half-done; the tail
// below runs uncancellable for the same reason.
}
}
/** What [setSyncedCollections] has to do: collections to attach, lists to let go. */
internal data class CollectionChange(
val attach: Set<TaskCollection>,
val drop: List<TaskListEntity>,
)
private fun sameServer(account: AccountEntity, found: CalDavDiscovery.Outcome.Found): Boolean {
val stored = account.principalUrl?.toHttpUrlOrNull() ?: return true
return stored.host.equals((found.movedTo ?: found.principal).host, ignoreCase = true)
}
internal companion object {
/** M3 primary-ish blue; the user recolours a list from its own screen. */
const val DEFAULT_LIST_COLOR = 0xFF4C6FFF.toInt()
/**
* Only collections the server still offers are judged. A synced list
* missing from [offered] — a share revoked a minute ago, a flaky listing —
* is not the user unticking it, and is left for sync to sort out.
*/
fun collectionChange(
offered: List<TaskCollection>,
selected: Set<HttpUrl>,
synced: List<TaskListEntity>,
): CollectionChange {
val syncedHrefs = synced.mapNotNull { it.href }.toSet()
val offeredHrefs = offered.map { it.url.toString() }.toSet()
val selectedHrefs = selected.map { it.toString() }.toSet()
return CollectionChange(
attach = offered.filter {
it.url in selected && it.url.toString() !in syncedHrefs
}.toSet(),
drop = synced.filter { it.href in offeredHrefs && it.href !in selectedHrefs },
)
}
}
}
@@ -0,0 +1,72 @@
package de.jeanlucmakiola.agendula.data.sync
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringSetPreferencesKey
import de.jeanlucmakiola.agendula.data.di.SyncStateDataStore
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import javax.inject.Inject
import javax.inject.Singleton
/**
* Which accounts the server has stopped accepting.
*
* Separate from `accounts.last_sync_error` because the two mean different
* things to the user and to the engine: an error is "this did not work, we will
* try again", while this is "**we have stopped trying** and only you can change
* that". Conflating them is how a client ends up retrying a revoked app password
* on a timer.
*
* Lives with the other per-device sync state, and is therefore excluded from
* backup — see [SyncStateDataStore]. That is also correct on its own terms: the
* credential does not survive a restore either, so a restored "needs sign-in" is
* at best redundant and at worst stale.
*/
@Singleton
class AccountStateStore @Inject constructor(
@SyncStateDataStore private val dataStore: DataStore<Preferences>,
) {
suspend fun needingSignIn(): Set<Long> = observeNeedingSignIn().first()
/** Observed, so a 401 during a background sync reaches an open screen. */
fun observeNeedingSignIn(): Flow<Set<Long>> = dataStore.data.map { prefs ->
prefs[KEY].orEmpty().mapNotNull { it.toLongOrNull() }.toSet()
}
suspend fun needsSignIn(accountId: Long): Boolean = accountId in needingSignIn()
suspend fun setNeedsSignIn(accountId: Long, needed: Boolean) {
dataStore.edit { prefs ->
val current = prefs[KEY].orEmpty().toMutableSet()
if (needed) current += accountId.toString() else current -= accountId.toString()
prefs[KEY] = current
// A later stop is news again.
if (!needed) prefs[NOTIFIED] = prefs[NOTIFIED].orEmpty() - accountId.toString()
}
}
/**
* Records that the user has been told [accountId] needs signing in to.
*
* @return true the first time per stop, so the notification is posted once
* rather than on every run the stopped account refuses.
*/
suspend fun markSignInNotified(accountId: Long): Boolean {
var fresh = false
dataStore.edit { prefs ->
val notified = prefs[NOTIFIED].orEmpty()
fresh = accountId.toString() !in notified
if (fresh) prefs[NOTIFIED] = notified + accountId.toString()
}
return fresh
}
private companion object {
val KEY = stringSetPreferencesKey("accounts_needing_sign_in")
val NOTIFIED = stringSetPreferencesKey("accounts_sign_in_notified")
}
}
@@ -0,0 +1,103 @@
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()
/**
* Removes the system-visible account.
*
* `removeAccountExplicitly` works because we own the account type. The Room
* row and the credential are removed by [AccountRepository]; pruning must be
* driven by this call and by `AccountManager`'s account-removed broadcast,
* never by "absent from the visible set" — `getAccountsByType` returns
* nothing while the device is locked, and treating that as removal is how a
* restore silently deletes the user's lists.
*/
fun remove(account: Account): Boolean = accountManager.removeAccountExplicitly(account)
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,127 @@
package de.jeanlucmakiola.agendula.data.sync
import de.jeanlucmakiola.agendula.data.di.IoDispatcher
import de.jeanlucmakiola.caldav.AppPassword
import de.jeanlucmakiola.caldav.CalDavDiscovery
import de.jeanlucmakiola.caldav.CalDavHttp
import de.jeanlucmakiola.caldav.DnsJavaResolver
import de.jeanlucmakiola.caldav.NextcloudLoginFlow
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.withContext
import okhttp3.HttpUrl
import java.util.concurrent.TimeUnit
import javax.inject.Inject
import javax.inject.Singleton
/**
* The network side of adding an account, behind one interface.
*
* It exists so the sign-in state machine can be tested. That machine decides
* which of five outcomes leads where, when a one-shot app password is spent, and
* which host a credential is scoped to — all of which are exactly the sort of
* thing that goes wrong quietly, and none of which should require a server to
* exercise.
*/
interface CalDavGateway {
/** Discovery against [target], optionally carrying credentials. */
suspend fun discover(target: String, credentials: Credentials? = null): CalDavDiscovery.Outcome
/** Starts Nextcloud Login Flow v2, or returns null if this is not a Nextcloud. */
suspend fun startLoginFlow(server: HttpUrl): NextcloudLoginFlow.Flow?
suspend fun pollLoginFlow(flow: NextcloudLoginFlow.Flow): NextcloudLoginFlow.PollResult
/**
* Hands an app password back to the server, best effort.
*
* Without it, uninstalling never revokes anything — the credential we minted
* outlives the app in the user's device list.
*/
suspend fun revokeAppPassword(credentials: Credentials): Boolean
/**
* The same, for a password the login flow just minted.
*
* ⚠️ Separate because [Credentials.origin] means something different here:
* the *server root* the flow reported, not a principal URL. Sending it
* through [revokeAppPassword] would derive the OCS root as though it were a
* principal, and a subpath install's `https://host/nextcloud/` would collapse
* to `https://host/` — a DELETE that 404s on every one of them.
*/
suspend fun revokeIssuedAppPassword(credentials: Credentials): Boolean
/** Credentials, and the origin whose registrable domain they are scoped to. */
data class Credentials(val username: String, val password: String, val origin: HttpUrl)
}
@Singleton
class OkHttpCalDavGateway @Inject constructor(
@IoDispatcher private val io: CoroutineDispatcher,
) : CalDavGateway {
/**
* Becomes the app password's **name** in Nextcloud's Settings → Security →
* Devices & sessions. OkHttp's default would show `okhttp/4.12.0`, leaving
* the user unable to tell what to revoke — which defeats the whole point of
* using an app password.
*/
private val userAgent = "Agendula (Android)"
override suspend fun discover(
target: String,
credentials: CalDavGateway.Credentials?,
): CalDavDiscovery.Outcome = withContext(io) {
val client = credentials?.let {
CalDavHttp.authenticated(userAgent, it.username, it.password, it.origin)
} ?: CalDavHttp.anonymous(userAgent)
CalDavDiscovery(client, DnsJavaResolver()).discover(target)
}
override suspend fun startLoginFlow(server: HttpUrl): NextcloudLoginFlow.Flow? =
withContext(io) {
NextcloudLoginFlow(CalDavHttp.anonymous(userAgent), userAgent)
.start(server, now())
.getOrNull()
}
/**
* ⚠️ The budget lives on the request, as it does for the revocation.
* `execute()` parks on a socket read that no cancellation can break, so the
* four places that cancel the poll job only stop the *next* request — and
* the shared client's ceiling is sized for a multiget, not for a two-second
* poll loop against a server that answered a moment ago.
*/
override suspend fun pollLoginFlow(flow: NextcloudLoginFlow.Flow): NextcloudLoginFlow.PollResult =
withContext(io) {
val client = CalDavHttp.anonymous(userAgent).newBuilder()
.callTimeout(POLL_TIMEOUT_SECONDS, TimeUnit.SECONDS)
.build()
NextcloudLoginFlow(client, userAgent).poll(flow, now())
}
override suspend fun revokeAppPassword(
credentials: CalDavGateway.Credentials,
): Boolean = withContext(io) {
val client = CalDavHttp.authenticated(
userAgent, credentials.username, credentials.password, credentials.origin,
)
AppPassword.revoke(client, credentials.origin)
}
override suspend fun revokeIssuedAppPassword(
credentials: CalDavGateway.Credentials,
): Boolean = withContext(io) {
val client = CalDavHttp.authenticated(
userAgent, credentials.username, credentials.password, credentials.origin,
)
AppPassword.revokeAt(client, credentials.origin)
}
private fun now() = System.currentTimeMillis() / 1000
private companion object {
/** One poll of a 2s loop. Long enough for a homelab, short enough to cancel. */
const val POLL_TIMEOUT_SECONDS = 15L
}
}
@@ -0,0 +1,72 @@
package de.jeanlucmakiola.agendula.data.sync
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringSetPreferencesKey
import de.jeanlucmakiola.agendula.data.di.SyncStateDataStore
import de.jeanlucmakiola.caldav.CollectionSupport
import kotlinx.coroutines.flow.first
import javax.inject.Inject
import javax.inject.Singleton
/**
* What each account's home set answered to OPTIONS, last time we asked.
*
* ⚠️ A cache, never the answer. The "new task list" affordance is *hidden*
* where neither MKCALENDAR nor extended
* MKCOL exists, and a picker that has to make a network round trip before it can
* draw a row is a picker that stutters — so the last answer is what it draws
* with, and [RemoteListRepository] re-asks before it actually writes. A server
* that gained the capability in an upgrade, or lost it in a config change, is
* then wrong for exactly one glance rather than for ever.
*/
@Singleton
class CollectionSupportStore @Inject constructor(
@SyncStateDataStore private val dataStore: DataStore<Preferences>,
) {
suspend fun get(accountId: Long): CollectionSupport =
decode(dataStore.data.first()[KEY].orEmpty())[accountId] ?: CollectionSupport.NONE
/** Asks [ask], records what it said, and hands it back. */
suspend fun refresh(accountId: Long, ask: () -> CollectionSupport): CollectionSupport {
val answer = ask()
dataStore.edit { prefs ->
// Re-read inside `edit`, which DataStore serialises: two accounts
// can be asked at once and a snapshot taken outside would drop one.
val current = decode(prefs[KEY].orEmpty()).toMutableMap()
current[accountId] = answer
prefs[KEY] = current.map { (id, support) -> encode(id, support) }.toSet()
}
return answer
}
suspend fun forget(accountId: Long) {
dataStore.edit { prefs ->
prefs[KEY] = decode(prefs[KEY].orEmpty())
.filterKeys { it != accountId }
.map { (id, support) -> encode(id, support) }
.toSet()
}
}
private fun encode(accountId: Long, support: CollectionSupport): String =
"$accountId|${support.mkCalendar}|${support.extendedMkCol}"
private fun decode(entries: Set<String>): Map<Long, CollectionSupport> =
entries.mapNotNull { entry ->
val parts = entry.split('|')
if (parts.size != FIELDS) return@mapNotNull null
val accountId = parts[0].toLongOrNull() ?: return@mapNotNull null
accountId to CollectionSupport(
mkCalendar = parts[1].toBooleanStrictOrNull() ?: return@mapNotNull null,
extendedMkCol = parts[2].toBooleanStrictOrNull() ?: return@mapNotNull null,
)
}.toMap()
private companion object {
val KEY = stringSetPreferencesKey("collection_support")
const val FIELDS = 3
}
}
File diff suppressed because it is too large Load Diff
@@ -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,145 @@
package de.jeanlucmakiola.agendula.data.sync
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.longPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import de.jeanlucmakiola.agendula.data.di.SyncStateDataStore
import de.jeanlucmakiola.caldav.NextcloudLoginFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import javax.inject.Inject
import javax.inject.Singleton
/**
* Where a started login flow is written down, so it can outlive this process.
*
* A seam for the same reason [AccountCreator] and [CalDavGateway] are: the flow
* that decides *when* a one-shot password stops being ours has to be testable
* without a DataStore.
*/
interface LoginFlowRecord {
/** Called **before** the browser is handed the URL. */
suspend fun remember(flow: NextcloudLoginFlow.Flow)
/** The flow is over, however it ended. */
suspend fun forget()
}
/**
* The Nextcloud login flow that is currently out at a browser.
*
* ⚠️ [NextcloudLoginFlow.Flow]'s own doc says to persist it **before** launching
* the browser, because the flow outlives our process — and it did not. The
* browser is a separate task, so process death while the user is approving is
* ordinary rather than exotic, and it stranded a one-shot app password that
* nothing could then collect *or* revoke: the flow's poll token was the only way
* back to it, and it lived in a ViewModel field.
*
* The token is not a credential. It authorises exactly one poll of one flow the
* user is in the middle of approving, and it is useless past the twenty-minute
* window — so it belongs in the sync-state store rather than the Keystore.
*
* ⚠️ What this does **not** cover is the window *after* approval, where the
* password itself lives only in memory. That needs the wizard's own state to
* survive, which is a different piece of work.
*/
@Singleton
class PendingLoginFlowStore @Inject constructor(
@SyncStateDataStore private val dataStore: DataStore<Preferences>,
private val gateway: CalDavGateway,
) : LoginFlowRecord {
private val lock = Mutex()
private var reclaimed = false
/** Records [flow] so a process that dies mid-approval can still finish with it. */
override suspend fun remember(flow: NextcloudLoginFlow.Flow) {
dataStore.edit { prefs ->
prefs[LOGIN_URL] = flow.loginUrl.toString()
prefs[POLL_ENDPOINT] = flow.pollEndpoint.toString()
prefs[POLL_TOKEN] = flow.pollToken
prefs[DEADLINE] = flow.deadlineEpochSeconds
}
}
/** The flow is finished, one way or another. */
override suspend fun forget() {
dataStore.edit { prefs ->
prefs.remove(LOGIN_URL)
prefs.remove(POLL_ENDPOINT)
prefs.remove(POLL_TOKEN)
prefs.remove(DEADLINE)
}
}
/**
* Collects and hands back a password nobody is left to own.
*
* Revoked rather than used: the address the user typed, the collections they
* ticked and the account name are all gone with the process, so there is
* nothing to finish. What is left is a live app password in the user's
* device list, under the same name as every other attempt — which is exactly
* what they cannot tell apart, and so dare not prune.
*
* ⚠️ **Once per process.** This activity is recreated on every rotation,
* theme switch and locale change, and a second run against a flow the *live*
* wizard is still polling would consume its one-shot 200 and revoke the
* password it was about to be handed. A flow remembered after this has run
* belongs to a wizard that is alive to finish it.
*/
suspend fun reclaim() {
lock.withLock {
if (reclaimed) return
reclaimed = true
}
val flow = pending() ?: return
when (val result = gateway.pollLoginFlow(flow)) {
is NextcloudLoginFlow.PollResult.Approved -> {
// Cleared first: a revocation that fails must not leave a token
// that would be polled again, and the 200 is already spent.
forget()
gateway.revokeIssuedAppPassword(
CalDavGateway.Credentials(
username = result.credentials.loginName,
password = result.credentials.appPassword,
origin = result.credentials.server,
),
)
}
is NextcloudLoginFlow.PollResult.Expired -> forget()
// Still inside the window, or the server had a moment. Either way
// the token is still worth something, so it is left for the next
// open; a poll past the deadline answers Expired and clears it.
NextcloudLoginFlow.PollResult.Pending,
is NextcloudLoginFlow.PollResult.Failed,
-> Unit
}
}
private suspend fun pending(): NextcloudLoginFlow.Flow? {
val prefs = dataStore.data.first()
val endpoint = prefs[POLL_ENDPOINT]?.toHttpUrlOrNull() ?: return null
val token = prefs[POLL_TOKEN] ?: return null
val deadline = prefs[DEADLINE] ?: return null
return NextcloudLoginFlow.Flow(
loginUrl = prefs[LOGIN_URL]?.toHttpUrlOrNull() ?: endpoint,
pollEndpoint = endpoint,
pollToken = token,
deadlineEpochSeconds = deadline,
)
}
private companion object {
val LOGIN_URL = stringPreferencesKey("login_flow_url")
val POLL_ENDPOINT = stringPreferencesKey("login_flow_poll_endpoint")
val POLL_TOKEN = stringPreferencesKey("login_flow_poll_token")
val DEADLINE = longPreferencesKey("login_flow_deadline")
}
}
@@ -0,0 +1,116 @@
package de.jeanlucmakiola.agendula.data.sync
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringSetPreferencesKey
import de.jeanlucmakiola.agendula.data.di.SyncStateDataStore
import kotlinx.coroutines.flow.first
import javax.inject.Inject
import javax.inject.Singleton
/**
* How many times each resource has failed, and therefore which ones to skip.
*
* ⚠️ Deliberately **not** a backoff. A backoff assumes the failure is transient
* and asks "how long until I try again"; the failures that matter here are
* permanent — a body sabre answers 415 for, a contradictory `RRULE`/`EXDATE`
* pair Nextcloud answers 500 for forever, a 507 the spec forbids retrying at
* all. The question worth asking is "how many times before I leave this one
* alone and finish the collection", and the answer is [THRESHOLD].
*
* Counts are cleared the moment a resource succeeds, so a genuinely transient
* failure costs nothing beyond the runs it actually failed in.
*/
@Singleton
class QuarantineStore @Inject constructor(
@SyncStateDataStore private val dataStore: DataStore<Preferences>,
) {
/** Current failure counts, keyed by [key]. */
suspend fun counts(): Map<String, Int> = decode(dataStore.data.first()[KEY].orEmpty())
private fun decode(entries: Set<String>): Map<String, Int> = entries.mapNotNull { entry ->
val separator = entry.lastIndexOf(COUNT_SEPARATOR)
if (separator <= 0) return@mapNotNull null
val count = entry.substring(separator + 1).toIntOrNull() ?: return@mapNotNull null
entry.substring(0, separator) to count
}.toMap()
/**
* Applies one account's changes without disturbing anyone else's.
*
* ⚠️ Not a whole-map replace. The counts are global — keyed by list, not by
* account — while `SyncWorker`'s uniqueness is only *per account*, so two
* accounts can sync at once. Each would snapshot the same global map and the
* later writer would discard the other's increments and resurrect the
* counters it had cleared. Re-reading inside `edit`, which DataStore
* serialises, keeps the read-modify-write atomic.
*
* @param updates counts to set, replacing any current value for those keys.
* @param cleared keys to remove outright, whatever they currently hold.
*/
suspend fun merge(updates: Map<String, Int>, cleared: Set<String>) {
dataStore.edit { prefs ->
val current = decode(prefs[KEY].orEmpty()).toMutableMap()
current -= cleared
current += updates.filterValues { it > 0 }
prefs[KEY] = current
.map { (key, count) -> "$key$COUNT_SEPARATOR$count" }
.toSet()
}
}
/**
* Forgets every count belonging to [listIds].
*
* ⚠️ The keys are global, exactly like the cadence cursors cleared beside
* them. A list detached from a removed account and re-attached to a new one
* would otherwise inherit its old counters — and a resource already at
* [THRESHOLD] is skipped for ever, since a quarantined resource never
* succeeds and so never clears.
*/
suspend fun forget(listIds: Set<Long>) {
if (listIds.isEmpty()) return
val prefixes = listIds.map { "$it|" }
dataStore.edit { prefs ->
val current = decode(prefs[KEY].orEmpty())
.filterKeys { key -> prefixes.none(key::startsWith) }
prefs[KEY] = current
.map { (key, count) -> "$key$COUNT_SEPARATOR$count" }
.toSet()
}
}
/**
* Clears the count for one resource in any of [listIds], so the next sync
* tries it again — the user's "retry" on a quarantined task.
*/
suspend fun release(listIds: Set<Long>, href: String) {
val keys = listIds.map { key(it, href) }.toSet()
dataStore.edit { prefs ->
val current = decode(prefs[KEY].orEmpty())
if (current.keys.none { it in keys }) return@edit
prefs[KEY] = (current - keys)
.map { (key, count) -> "$key$COUNT_SEPARATOR$count" }
.toSet()
}
}
companion object {
/**
* Attempts before a resource is left alone.
*
* Three rather than one: a 502 from a reverse proxy mid-restart and a
* permanently malformed body arrive as the same outcome, and burning two
* extra runs is cheaper than quarantining a resource that would have
* worked.
*/
const val THRESHOLD = 3
fun key(listId: Long, href: String) = "$listId|$href"
private const val COUNT_SEPARATOR = '#'
private val KEY = stringSetPreferencesKey("sync_quarantine")
}
}
@@ -0,0 +1,282 @@
package de.jeanlucmakiola.agendula.data.sync
import de.jeanlucmakiola.agendula.data.di.IoDispatcher
import de.jeanlucmakiola.agendula.data.sync.RemoteLists.Outcome
import de.jeanlucmakiola.agendula.data.sync.push.PushStore
import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver
import de.jeanlucmakiola.agendula.data.tasks.StorageMode
import de.jeanlucmakiola.agendula.data.tasks.room.AccountEntity
import de.jeanlucmakiola.agendula.data.tasks.room.TaskListEntity
import de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase
import de.jeanlucmakiola.caldav.CalDavHttp
import de.jeanlucmakiola.caldav.CollectionAdmin
import de.jeanlucmakiola.caldav.CollectionOutcome
import de.jeanlucmakiola.caldav.CollectionSupport
import de.jeanlucmakiola.caldav.DavCollectionAdmin
import de.jeanlucmakiola.caldav.ResourceNames
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.withContext
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.OkHttpClient
import javax.inject.Inject
import javax.inject.Singleton
/**
* Task lists that live on a server: making them, renaming them, recolouring
* them and deleting them.
*
* ⚠️ Every write here is **server first, Room second**, and that ordering is the
* whole design. The other way round gives the user a list that exists on their
* phone and nowhere else, and nothing to tell them so — `task_lists.is_dirty`
* was already set by a rename and read by nobody, which is precisely that
* failure with the evidence discarded. A refused write leaves the local row
* exactly as it was, so what is on screen is what is on the server.
*
* Device-only lists are not this class's business: they have no href, no
* account and nothing to ask permission of. [de.jeanlucmakiola.agendula.data.tasks.TasksRepository]
* keeps them.
*/
@Singleton
class RemoteListRepository @Inject constructor(
private val database: TasksDatabase,
private val credentials: CredentialStore,
private val support: CollectionSupportStore,
private val syncTrigger: SyncTrigger,
private val cadence: SyncCadenceStore,
private val notices: SyncNoticeStore,
private val quarantine: QuarantineStore,
private val accountState: AccountStateStore,
private val resolver: ProviderResolver,
private val push: PushStore,
@IoDispatcher private val io: CoroutineDispatcher,
) : RemoteLists {
/**
* The accounts a new list may be created on, freshest answer first.
*
* ⚠️ Re-asked rather than cached for ever. `CollectionSupportStore` holds
* the last answer so a picker can draw immediately, but a server that gained
* the capability in an upgrade — or lost it in a config change — must be
* able to say so, and the only moment that costs nothing is while the user
* is looking at the picker.
*/
override suspend fun creatableAccounts(): List<AccountEntity> = withContext(io) {
// ⚠️ Empty in External mode, whatever the accounts table holds. The
// lists on screen then come from a third-party provider, so a row
// inserted into ours would exist, sync, and be visible to nobody.
if (resolver.mode() != StorageMode.OWN) return@withContext emptyList()
val stopped = accountState.needingSignIn()
val candidates = database.accounts().all().filter {
it.homeSetUrl?.toHttpUrlOrNull() != null && it.id !in stopped
}
// ⚠️ Together, not one after another. Each probe is a blocking OPTIONS,
// so three accounts with one server on a slow link held the "Where" row
// off the sheet for the sum of all three — with the sheet already drawn.
coroutineScope {
candidates.map { account -> async { account to supportFor(account) } }
.awaitAll()
.filter { (_, support) -> support.canCreate }
.map { (account, _) -> account }
}
}
/**
* Makes a collection on [accountId]'s home set and a row pointing at it.
*
* @return the new list's local id, or why there is none.
*/
override suspend fun create(
accountId: Long,
name: String,
color: Int,
): Outcome = withContext(io) {
// ⚠️ Re-checked here, not only in `creatableAccounts`. The picker's list
// is a StateFlow that outlives one opening of the sheet, so a mode that
// flips between the list being built and Save being tapped would
// otherwise create the collection on the server and file the row in a
// store External mode never reads.
if (resolver.mode() != StorageMode.OWN) return@withContext Outcome.NoAccount
val account = database.accounts().account(accountId) ?: return@withContext Outcome.NoAccount
val homeSet = account.homeSetUrl?.toHttpUrlOrNull()
?: return@withContext Outcome.NoAccount
val admin = adminFor(account) ?: return@withContext Outcome.NoAccount
val capabilities = support.refresh(accountId) { admin.support(homeSet) }
if (!capabilities.canCreate) return@withContext Outcome.Unsupported
// ⚠️ A second attempt, but only for the one refusal a different name
// can fix. Nextcloud's trashbin *renames* a deleted collection rather
// than removing it, so re-creating under a segment used before answers
// 403 for ever — and "Shopping" is exactly the name someone deletes and
// remakes. Retrying anything else spends a second authenticated write
// that will fail the same way, and worse: a 401 is a second hit on the
// brute-force counter, and a 507 retried reports the wrong code back,
// since the caller only ever sees the *last* attempt's.
val first = ResourceNames.forCollection(name)
var created = admin.create(homeSet, first, name, color, capabilities)
if (created is CollectionOutcome.Refused && created.code in NAME_REFUSALS) {
created = admin.create(homeSet, ResourceNames.randomCollection(), name, color, capabilities)
}
when (created) {
is CollectionOutcome.Created -> {
// ⚠️ Uncancellable. The collection exists on the server from
// here on, and a cancellation between that and the row would
// leave one the app has no record of and no way to reach —
// visible only on the next full account re-add.
withContext(NonCancellable) {
database.taskLists().insert(
TaskListEntity(
name = name,
color = color,
accountId = accountId,
href = created.url.toString(),
),
)
}
// The server has it and we do not; a sync is how the two agree
// on a ctag and a token rather than reconciling in full later.
syncTrigger.enqueue(account.displayName, expedited = true)
Outcome.Done
}
is CollectionOutcome.Refused -> Outcome.Refused(created.code)
is CollectionOutcome.Failed -> Outcome.Unreachable
CollectionOutcome.Unsupported -> Outcome.Unsupported
CollectionOutcome.Updated -> Outcome.Unexpected
}
}
/**
* Renames and recolours [listId] on the server, then locally.
*
* ⚠️ Refuses a read-only collection rather than discovering it at write
* time. A share the owner has made read-only answers 403 to a PROPPATCH, and
* a row that has already been renamed locally by then reads as a rename that
* worked and then quietly reverted on the next sync.
*/
override suspend fun rename(listId: Long, name: String, color: Int): Outcome = withContext(io) {
val list = database.taskLists().entity(listId) ?: return@withContext Outcome.NoAccount
if (list.isReadOnly) return@withContext Outcome.ReadOnly
val url = list.href?.toHttpUrlOrNull() ?: return@withContext Outcome.NoAccount
val account = list.accountId?.let { database.accounts().account(it) }
?: return@withContext Outcome.NoAccount
val admin = adminFor(account) ?: return@withContext Outcome.NoAccount
when (val outcome = admin.updateProperties(url, displayName = name, color = color)) {
CollectionOutcome.Updated -> {
withContext(NonCancellable) {
// Read again inside the write: a sync running alongside this
// may have refreshed the ACL flag or the cursor, and writing
// back the entity we read before the network call would
// revert it.
val current = database.taskLists().entity(listId) ?: return@withContext
database.taskLists().update(
// isDirty stays false: the server already has this. The
// flag existed for a PROPPATCH that never happened.
current.copy(name = name, color = color, isDirty = false),
)
}
Outcome.Done
}
is CollectionOutcome.Refused -> Outcome.Refused(outcome.code)
is CollectionOutcome.Failed -> Outcome.Unreachable
is CollectionOutcome.Created, CollectionOutcome.Unsupported -> Outcome.Unexpected
}
}
/**
* Deletes [listId] on the server, then on the device.
*
* ⚠️ The one write where "already gone" is success — [CollectionAdmin.delete]
* grades 404 and 410 that way — because otherwise a collection someone
* removed from another client leaves a row here that nothing can get rid of.
*/
override suspend fun delete(listId: Long): Outcome = withContext(io) {
val list = database.taskLists().entity(listId) ?: return@withContext Outcome.Done
if (list.isReadOnly) return@withContext Outcome.ReadOnly
val url = list.href?.toHttpUrlOrNull() ?: return@withContext Outcome.NoAccount
val account = list.accountId?.let { database.accounts().account(it) }
?: return@withContext Outcome.NoAccount
val admin = adminFor(account) ?: return@withContext Outcome.NoAccount
when (val outcome = admin.delete(url)) {
CollectionOutcome.Updated -> {
withContext(NonCancellable) {
// `tasks.list_id` is ON DELETE CASCADE, so the tasks go with
// it — which is what was just done on the server.
database.taskLists().delete(listId)
// And the per-list state keyed off it, exactly as removing an
// account clears its lists': the ids are AUTOINCREMENT so
// nothing would ever read these again. The notices go by
// *name*, which is how they are keyed — a discarded-edit
// notice would otherwise name a list that no longer exists
// until the user tapped "Got it".
forgetPerListState(listId)
list.accountId?.let { notices.forgetList(it, list.name) }
}
Outcome.Done
}
is CollectionOutcome.Refused -> Outcome.Refused(outcome.code)
is CollectionOutcome.Failed -> Outcome.Unreachable
is CollectionOutcome.Created, CollectionOutcome.Unsupported -> Outcome.Unexpected
}
}
private suspend fun supportFor(account: AccountEntity): CollectionSupport {
val homeSet = account.homeSetUrl?.toHttpUrlOrNull() ?: return CollectionSupport.NONE
val admin = adminFor(account) ?: return CollectionSupport.NONE
return support.refresh(account.id) { admin.support(homeSet) }
}
/**
* Null when the account has no credential we can use — a stopped account, or
* a restore.
*
* ⚠️ The stopped check is the same one `SyncEngine.sync` makes before it
* touches the network, and for the same reason: Nextcloud's brute-force
* protection throttles and then **429s per source IP**, so spending a
* request on a credential we already know the server rejects lands on the
* user's *other* clients. Opening the "new list" sheet must not do that any
* more than a timer may.
*/
private suspend fun adminFor(account: AccountEntity): CollectionAdmin? {
if (accountState.needsSignIn(account.id)) return null
val username = account.username ?: return null
val origin = account.principalUrl?.toHttpUrlOrNull() ?: return null
val password = (credentials.get(account.id) as? CredentialStore.Secret.Present)?.value
?: return null
return DavCollectionAdmin(client(username, password, origin))
}
private fun client(username: String, password: String, origin: HttpUrl): OkHttpClient =
CalDavHttp.authenticated(USER_AGENT, username, password, origin)
private suspend fun forgetPerListState(listId: Long) {
val ids = setOf(listId)
cadence.forget(ids)
quarantine.forget(ids)
// The subscription went with the collection on the server.
push.forget(ids)
}
private companion object {
/** The same agent the sync and the add flow use, so the server names us once. */
const val USER_AGENT = "Agendula (Android)"
/**
* Refusals a different path segment can get past, and only those.
*
* 403 is Nextcloud's trashbin still holding the name; 405 is a server
* answering "already a collection there". Everything else — 401, 409,
* 423, 507 — means the same thing under any name.
*/
val NAME_REFUSALS = setOf(403, 405)
}
}
@@ -0,0 +1,96 @@
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.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**.
* `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 = SyncTrigger(context).enqueue(account.name)
// 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.
*
* 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.
*
* 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,32 @@
package de.jeanlucmakiola.agendula.data.sync
import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver
import de.jeanlucmakiola.agendula.data.tasks.StorageMode
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import javax.inject.Inject
import javax.inject.Singleton
/**
* Whether CalDAV accounts can do anything right now.
*
* Sync writes into Agendula's own store. In External mode the screens read a
* third-party provider instead, so an account would sync into rows nobody sees.
* Read from the stored preference rather than [ProviderResolver.mode], which is
* only current once `StorageModeHolder` has mirrored it — and a worker can start
* before that.
*/
@Singleton
class SyncAvailability @Inject constructor(
private val prefs: SettingsPrefs,
private val resolver: ProviderResolver,
) {
suspend fun accountsUsable(): Boolean = observe().first()
fun observe(): Flow<Boolean> = prefs.storageMode.map { stored ->
(stored ?: resolver.autoMode()) == StorageMode.OWN
}
}
@@ -0,0 +1,92 @@
package de.jeanlucmakiola.agendula.data.sync
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringSetPreferencesKey
import de.jeanlucmakiola.agendula.data.di.SyncStateDataStore
import kotlinx.coroutines.flow.first
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.time.Duration
import kotlin.time.Duration.Companion.hours
import kotlin.time.Instant
/**
* When each collection was last reconciled against a full listing.
*
* ⚠️ This is the mitigation for the one RFC 6578 failure that has no signal at
* all: a token the server still accepts, over a change log it has already
* pruned, answers `207` with zero changes and no error. Nothing in the protocol
* distinguishes that from "nothing happened". The only defence is to stop
* trusting the token periodically and diff a real listing — so the full path is
* a permanent safety net, not a fallback, and this is its clock.
*
* Kept out of Room deliberately: it is scheduling bookkeeping, not user data,
* and it must never be part of a backup that could restore a stale "we checked
* recently" into a fresh install.
*/
@Singleton
class SyncCadenceStore @Inject constructor(
@SyncStateDataStore private val dataStore: DataStore<Preferences>,
) {
/**
* Last *scheduled* full reconciliation per list id.
*
* ⚠️ Not "the last time a full listing was read". A collection whose server
* has no `sync-collection` support reads one on every run, and recording
* each would keep this permanently fresh — so nothing hung off the periodic
* mark would ever come due again.
*/
suspend fun lastFullSync(): Map<Long, Instant> =
dataStore.data.first()[KEY].orEmpty().mapNotNull { entry ->
val separator = entry.lastIndexOf(SEPARATOR)
if (separator <= 0) return@mapNotNull null
val id = entry.substring(0, separator).toLongOrNull() ?: return@mapNotNull null
val at = entry.substring(separator + 1).toLongOrNull() ?: return@mapNotNull null
id to Instant.fromEpochSeconds(at)
}.toMap()
/** Merges, rather than replacing, so concurrent accounts do not erase each other. */
suspend fun record(reconciled: Map<Long, Instant>) {
if (reconciled.isEmpty()) return
dataStore.edit { prefs ->
val current = prefs[KEY].orEmpty()
.mapNotNull { entry ->
val separator = entry.lastIndexOf(SEPARATOR)
if (separator <= 0) null else entry.substring(0, separator) to entry
}
.toMap()
.toMutableMap()
reconciled.forEach { (id, at) ->
current["$id"] = "$id$SEPARATOR${at.epochSeconds}"
}
prefs[KEY] = current.values.toSet()
}
}
/** Forgets a list, so a re-added account starts from a full reconciliation. */
suspend fun forget(listIds: Set<Long>) {
if (listIds.isEmpty()) return
dataStore.edit { prefs ->
prefs[KEY] = prefs[KEY].orEmpty().filterNot { entry ->
entry.substringBefore(SEPARATOR).toLongOrNull() in listIds
}.toSet()
}
}
companion object {
/**
* How long a sync token is trusted before a full listing is diffed anyway.
*
* Long enough that the incremental path still carries almost every sync,
* short enough that a silently pruned change log is a day's divergence
* rather than an indefinite one.
*/
val FULL_RECONCILIATION_INTERVAL: Duration = 24.hours
private const val SEPARATOR = '@'
private val KEY = stringSetPreferencesKey("sync_last_full")
}
}
@@ -0,0 +1,257 @@
package de.jeanlucmakiola.agendula.data.sync
import de.jeanlucmakiola.agendula.data.di.IoDispatcher
import de.jeanlucmakiola.agendula.data.sync.push.PushRegistrar
import de.jeanlucmakiola.agendula.data.tasks.room.AccountEntity
import de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase
import de.jeanlucmakiola.caldav.CalDavHttp
import de.jeanlucmakiola.caldav.CalendarCollection
import de.jeanlucmakiola.caldav.RemoteCalendar
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.withContext
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import javax.inject.Inject
import javax.inject.Singleton
/**
* Syncs one account: every list it owns, against the collection each points at.
*
* ⚠️ **A failed collection must not fail the account.** One revoked share, one
* calendar the server 500s on, must not stop the other four from syncing — so
* every collection's outcome is a [SyncReport] rather than an exception, and the
* account's own result is the list of them.
*/
@Singleton
class SyncEngine @Inject constructor(
private val database: TasksDatabase,
private val store: RoomSyncStore,
private val credentials: CredentialStore,
private val quarantine: QuarantineStore,
private val cadence: SyncCadenceStore,
private val accountState: AccountStateStore,
private val notices: SyncNoticeStore,
private val availability: SyncAvailability,
private val push: PushRegistrar,
@IoDispatcher private val io: CoroutineDispatcher,
) {
/** Why an account could not be synced at all, as opposed to one of its lists. */
sealed interface Result {
data class Synced(
val reports: List<SyncReport>,
/**
* What this run destroyed or gave up on that was not already on
* record — the caller's cue to say so out loud.
*/
val notices: List<SyncNotice> = emptyList(),
) : Result
/** The credential is gone or undecryptable: only re-authentication helps. */
data class NeedsSignIn(val accountId: Long, val reason: String) : Result
data class Misconfigured(val reason: String) : Result
/** External storage mode: nothing reads what a sync would write, so none runs. */
data object Paused : Result
}
suspend fun sync(accountName: String): Result = withContext(io) {
if (!availability.accountsUsable()) return@withContext Result.Paused
val account = database.accounts().all().firstOrNull { it.displayName == accountName }
?: return@withContext Result.Misconfigured("no such account: $accountName")
// ⚠️ Before anything reaches the network. A periodic request that outlives
// the stop — or a manual trigger on a stopped account — must not spend a
// request on a credential we already know the server rejects: Nextcloud
// throttles then 429s per source IP, and that lands on the user's other
// clients rather than on us.
if (accountState.needsSignIn(account.id)) {
return@withContext Result.NeedsSignIn(account.id, "waiting for you to sign in again")
}
val username = account.username
?: return@withContext fatal(account.id, Result.Misconfigured("account has no username"))
val origin = account.principalUrl?.toHttpUrlOrNull()
?: return@withContext fatal(
account.id,
Result.Misconfigured("account has no principal URL"),
)
val password = when (val secret = credentials.get(account.id)) {
is CredentialStore.Secret.Present -> secret.value
CredentialStore.Secret.Absent -> {
stopForSignIn(account.id, "no stored password")
return@withContext Result.NeedsSignIn(account.id, "no stored password")
}
is CredentialStore.Secret.Unrecoverable -> {
stopForSignIn(account.id, secret.reason)
return@withContext Result.NeedsSignIn(account.id, secret.reason)
}
}
val client = CalDavHttp.authenticated(USER_AGENT, username, password, origin)
val subscriptions = push.subscriptionsByHref(account.id)
val reports = syncCollections(account) { url ->
CalendarCollection(client, url, pushRegistration = subscriptions[url.toString()])
}
// ⚠️ Before the auth check, not after it. A 401 on one collection does
// not un-discard an edit another collection already destroyed, and
// returning NeedsSignIn past this point would drop the record of it.
// Outside `syncCollections` because that is driven without a network by
// the reconciliation tests, which have nothing to say about notices.
val fresh = notices.record(
accountId = account.id,
at = kotlin.time.Clock.System.now(),
reports = reports,
titles = quarantinedTitles(reports),
)
if (reports.any { it.authFailure }) {
// ⚠️ Stop the account rather than let the schedule keep trying.
// Nextcloud throttles and then 429s **per source IP**, so a timer on a
// dead app password degrades every other Nextcloud client on the
// user's network — and there is nothing here to retry: the fix is a
// sign-in only the user can perform.
stopForSignIn(account.id, "the server rejected the credentials")
return@withContext Result.NeedsSignIn(account.id, "the server rejected the credentials")
}
accountState.setNeedsSignIn(account.id, false)
// Never fails the sync: push is an optimisation on top of the schedule.
runCatching { push.onSynced(account, reports) }
Result.Synced(reports, fresh)
}
/**
* The local title of each quarantined resource, by href.
*
* ⚠️ Resolved here rather than left to the store, which has no database.
* Without it the user is told "a task has stopped syncing" over a row
* reading `a1f9c3e2-….ics` — the opaque blob `SyncNoticeStore` refuses to
* show for a discarded edit, and unactionable for exactly the same reason.
* A resource we have never stored has no title to find, and its filename is
* then genuinely all there is.
*/
private fun quarantinedTitles(reports: List<SyncReport>): Map<String, String> =
reports.filter { it.quarantined.isNotEmpty() }
.flatMap { report ->
val wanted = report.quarantined.mapTo(mutableSetOf()) { it.href }
store.rowsIn(report.listId)
.filter { it.href in wanted && !it.title.isNullOrBlank() }
.map { it.href!! to it.title!! }
}
.toMap()
/**
* Records why the account could not be synced at all.
*
* ⚠️ Without this the row keeps its old `lastSyncAt`, and the accounts screen
* goes on reporting "synced 5 minutes ago" for an account whose credential
* can no longer be decrypted — the silent failure the account layer exists to
* avoid.
*/
/**
* Marks an account as stopped until the user signs in again.
*
* ⚠️ It does **not** cancel the work, even though stopping the timer is the
* whole point — because this runs *inside* `SyncWorker`, and one of the two
* unique names it would cancel is the WorkSpec currently executing us.
* WorkManager would interrupt the coroutine, so `Result.NeedsSignIn` would
* never be returned and the adapter would see CANCELLED rather than FAILED.
*
* The flag does the work instead: [sync] refuses before touching the network,
* so a firing that survives costs nothing, and [AccountRepository.rescheduleAll]
* cancels the schedule from outside any worker.
*/
private suspend fun stopForSignIn(accountId: Long, reason: String) {
accountState.setNeedsSignIn(accountId, true)
database.accounts().recordSync(accountId, at = null, error = reason)
}
private fun fatal(accountId: Long, result: Result): Result {
val reason = when (result) {
is Result.NeedsSignIn -> result.reason
is Result.Misconfigured -> result.reason
is Result.Synced, Result.Paused -> return result
}
database.accounts().recordSync(accountId, at = null, error = reason)
return result
}
/** Split out from [sync] so the reconciliation can be driven without a network. */
internal suspend fun syncCollections(
account: AccountEntity,
remoteFor: (HttpUrl) -> RemoteCalendar,
): List<SyncReport> {
// Lists owing a DELETE first: a task moved between two of this account's
// collections then leaves the old one before it arrives in the new one,
// which a server that keeps UIDs unique per account needs.
val owing = database.tasks().listsWithTombstones().toSet()
val lists = database.taskLists().syncedForAccount(account.id)
.sortedBy { it.id !in owing }
val listIds = lists.map { it.id }.toSet()
// ⚠️ Only this account's keys are written back. The counts are global
// while the worker's uniqueness is only per account, so replacing the
// whole map would discard a concurrently syncing account's increments and
// resurrect the counters it had cleared.
val before = quarantine.counts()
val counts = before.toMutableMap()
val syncer = CollectionSyncer(store)
val now = kotlin.time.Clock.System.now()
val lastFull = cadence.lastFullSync()
// Never reconciled, or the token has been trusted long enough.
val due = lists.associate { list ->
val since = lastFull[list.id]
list.id to (since == null || now - since >= SyncCadenceStore.FULL_RECONCILIATION_INTERVAL)
}
val reports = lists.map { list ->
val url = list.href?.toHttpUrlOrNull()
?: return@map SyncReport(list.id, list.name, failure = "list has no collection URL")
syncer.sync(
list = list,
remote = remoteFor(url),
quarantine = counts,
fullReconciliationDue = due[list.id] == true,
)
}
// ⚠️ Only the runs that were *due*. A server without `sync-collection`
// reconciles in full every single time, so recording each one kept the
// clock permanently fresh and `fullReconciliationDue` permanently false
// — which costs nothing on that path, since the cursor is null anyway,
// but silently disables everything else hung off the periodic mark. The
// download-side quarantine probe is the one that matters: for exactly
// those servers it would never have fired.
cadence.record(
reports.filter { it.reconciledInFull && it.failure == null && due[it.listId] == true }
.associate { it.listId to now },
)
fun mine(key: String) = key.substringBefore('|').toLongOrNull() in listIds
quarantine.merge(
updates = counts.filterKeys(::mine),
cleared = before.keys.filter(::mine).filterNot { it in counts }.toSet(),
)
database.accounts().recordSync(
accountId = account.id,
at = now,
error = reports.mapNotNull { it.failure }.firstOrNull(),
)
return reports
}
private companion object {
/**
* Matches what the account-add flow signed in with, so Nextcloud's
* Settings → Security → Devices & sessions keeps naming the app password
* after the app rather than after OkHttp.
*/
const val USER_AGENT = "Agendula (Android)"
}
}
@@ -0,0 +1,81 @@
package de.jeanlucmakiola.agendula.data.sync
/**
* Why a sync failed, in classes a user can act on.
*
* `accounts.last_sync_error` holds the engine's own words — a collection
* failure wrapping an exception's `toString()` — which are for logs and never
* for the screen. This reads the class back out of them.
*/
data class SyncFailure(val kind: Kind, val httpCode: Int? = null) {
enum class Kind {
/** The server refused the credentials. */
SIGN_IN,
/** DNS, a refused connection, a timeout: nothing answered. */
UNREACHABLE,
/** The TLS handshake failed — an untrusted or mismatched certificate. */
CERTIFICATE,
/** The server answered, with an error of its own. */
SERVER,
/** The account or a list is missing something the sync needs. */
MISCONFIGURED,
/** Anything else: the collection did not finish, for a reason we do not name. */
OTHER,
}
companion object {
fun of(error: String): SyncFailure {
val code = HTTP_CODE.find(error)?.groupValues?.get(1)?.toIntOrNull()
return when {
TLS.any { it in error } -> SyncFailure(Kind.CERTIFICATE)
AUTH.any { it in error } || code == 401 -> SyncFailure(Kind.SIGN_IN)
NETWORK.any { it in error } -> SyncFailure(Kind.UNREACHABLE)
CONFIG.any { it in error } -> SyncFailure(Kind.MISCONFIGURED)
"ServiceUnavailableException" in error -> SyncFailure(Kind.SERVER, code ?: 503)
code != null -> SyncFailure(Kind.SERVER, code)
else -> SyncFailure(Kind.OTHER)
}
}
private val HTTP_CODE = Regex("""\bHTTP (\d{3})\b""")
private val TLS = listOf(
"SSLHandshakeException",
"SSLPeerUnverifiedException",
"CertPathValidatorException",
"CertificateException",
"SSLException",
)
private val AUTH = listOf(
"UnauthorizedException",
"rejected the credentials",
"no stored password",
)
private val NETWORK = listOf(
"UnknownHostException",
"ConnectException",
"NoRouteToHostException",
"SocketTimeoutException",
"InterruptedIOException",
"SocketException",
"EOFException",
"timeout",
)
private val CONFIG = listOf(
"no such account",
"has no username",
"has no principal URL",
"has no collection URL",
)
}
}
@@ -0,0 +1,189 @@
package de.jeanlucmakiola.agendula.data.sync
import android.Manifest
import android.annotation.SuppressLint
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat
import dagger.hilt.android.qualifiers.ApplicationContext
import de.jeanlucmakiola.agendula.MainActivity
import de.jeanlucmakiola.agendula.data.di.ChannelRefresher
import de.jeanlucmakiola.agendula.R
import javax.inject.Inject
import javax.inject.Singleton
/**
* Tells the user what a background sync destroyed or gave up on.
*
* ⚠️ A notification, and not only a row on the accounts screen. Sync runs on a
* four-hour timer while the app is closed, so a surface the user has to go and
* look at means the discarded edit is discovered — if ever — days later, next to
* a task that quietly says something else than what they typed. The account
* screen keeps the detail; this is what makes them go there.
*
* Its own channel, at `IMPORTANCE_LOW`: it is a report rather than an alarm, and
* it must be silenceable without taking due-task reminders with it.
*/
@Singleton
class SyncNoticeNotifier @Inject constructor(
@ApplicationContext private val context: Context,
) : ChannelRefresher {
fun canPost(): Boolean {
val granted = Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) ==
PackageManager.PERMISSION_GRANTED
return granted && NotificationManagerCompat.from(context).areNotificationsEnabled()
}
// canPost() checks POST_NOTIFICATIONS before we ever call notify().
@SuppressLint("MissingPermission")
fun post(accountName: String, notices: List<SyncNotice>) {
if (notices.isEmpty() || !canPost()) return
ensureChannel()
val discarded = notices.count { it.kind == SyncNotice.Kind.DISCARDED_EDIT }
val quarantined = notices.size - discarded
// ⚠️ A discarded edit outranks a quarantine even when there are more
// quarantines, and the collapsed line says so. They are not equivalent:
// an edit that lost is work already destroyed and unrecoverable, while a
// quarantined task is a condition that persists and clears itself. The
// big text below lists both, in full, whichever headline was chosen.
val title = if (discarded > 0) {
context.resources.getQuantityString(
R.plurals.sync_notice_discarded_title, discarded, discarded,
)
} else {
context.resources.getQuantityString(
R.plurals.sync_notice_quarantined_title, quarantined, quarantined,
)
}
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle(title)
.setContentText(context.getString(R.string.sync_notice_body, accountName))
.setStyle(NotificationCompat.BigTextStyle().bigText(summaryOf(notices)))
.setCategory(NotificationCompat.CATEGORY_STATUS)
.setPriority(NotificationCompat.PRIORITY_LOW)
.setAutoCancel(true)
.setContentIntent(
PendingIntent.getActivity(
context,
accountName.hashCode(),
MainActivity.openIntent(context),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
),
)
.build()
// Tagged by account, so a second account's news replaces nothing.
NotificationManagerCompat.from(context).notify(accountName, NOTIFICATION_ID, notification)
}
/** "Sign in to <account> again", for a background sync the server refused. */
// canPost() checks POST_NOTIFICATIONS before we ever call notify().
@SuppressLint("MissingPermission")
fun postSignIn(accountName: String, accountId: Long) {
if (!canPost()) return
ensureSignInChannel()
val body = context.getString(R.string.sync_sign_in_body)
val notification = NotificationCompat.Builder(context, SIGN_IN_CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle(context.getString(R.string.sync_sign_in_title, accountName))
.setContentText(body)
.setStyle(NotificationCompat.BigTextStyle().bigText(body))
.setCategory(NotificationCompat.CATEGORY_ERROR)
.setAutoCancel(true)
.setContentIntent(
PendingIntent.getActivity(
context,
accountId.toInt(),
signInIntent(accountId),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
),
)
.build()
NotificationManagerCompat.from(context).notify(accountName, SIGN_IN_NOTIFICATION_ID, notification)
}
/** The account syncs again, so the prompt has done its job. */
fun cancelSignIn(accountName: String) {
NotificationManagerCompat.from(context).cancel(accountName, SIGN_IN_NOTIFICATION_ID)
}
/**
* Where tapping the sign-in prompt lands. Only opens the app for now; the
* extra names the account for routing to Settings → Accounts → it.
*/
private fun signInIntent(accountId: Long): Intent =
MainActivity.openIntent(context).putExtra(MainActivity.EXTRA_SIGN_IN_ACCOUNT_ID, accountId)
private fun ensureSignInChannel() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
val manager = context.getSystemService(NotificationManager::class.java)
if (manager.getNotificationChannel(SIGN_IN_CHANNEL_ID) != null) return
manager.createNotificationChannel(
NotificationChannel(
SIGN_IN_CHANNEL_ID,
context.getString(R.string.sync_sign_in_channel_name),
NotificationManager.IMPORTANCE_DEFAULT,
).apply { description = context.getString(R.string.sync_sign_in_channel_desc) },
)
}
/**
* The first few, by name.
*
* ⚠️ A count on its own is unactionable — "3 edits were replaced" leaves the
* user to guess which three, across every list they own. The names are the
* only part that makes the account screen worth opening.
*/
private fun summaryOf(notices: List<SyncNotice>): String {
val named = notices.take(SUMMARY_LIMIT).joinToString("\n") { notice ->
val subject = notice.subject.ifBlank { context.getString(R.string.task_untitled) }
context.getString(R.string.sync_notice_line, subject, notice.listName)
}
val rest = notices.size - SUMMARY_LIMIT
return if (rest > 0) {
named + "\n" + context.resources.getQuantityString(R.plurals.sync_notice_more, rest, rest)
} else {
named
}
}
private fun ensureChannel() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
context.getSystemService(NotificationManager::class.java).createNotificationChannel(
NotificationChannel(
CHANNEL_ID,
context.getString(R.string.sync_notice_channel_name),
NotificationManager.IMPORTANCE_LOW,
).apply { description = context.getString(R.string.sync_notice_channel_desc) },
)
}
/** Re-create the channel, if it exists, in the current language. */
override fun refreshChannel() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
val manager = context.getSystemService(NotificationManager::class.java)
if (manager.getNotificationChannel(CHANNEL_ID) != null) ensureChannel()
}
companion object {
private const val CHANNEL_ID = "sync_notices"
private const val NOTIFICATION_ID = 2
private const val SIGN_IN_CHANNEL_ID = "account_sign_in"
private const val SIGN_IN_NOTIFICATION_ID = 3
/** Enough to recognise the work; the screen has the rest. */
private const val SUMMARY_LIMIT = 5
}
}
@@ -0,0 +1,279 @@
package de.jeanlucmakiola.agendula.data.sync
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringSetPreferencesKey
import de.jeanlucmakiola.agendula.data.di.SyncStateDataStore
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import java.util.Base64
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.time.Instant
/**
* One thing a sync did that the user would not otherwise find out about.
*
* ⚠️ Conflict policy is **server wins, local edit discarded**, and
* [SyncReport]'s own doc says the report is "the other half of the decision, not
* a nice-to-have". Until this existed the other half was a `Log.i` — the edit was
* gone, nothing in `ui/` read `discardedEdits`, and from where the user sits that
* is indistinguishable from the app losing their work.
*/
data class SyncNotice(
val accountId: Long,
val listName: String,
val kind: Kind,
/** The task's title for a discarded edit, the resource's name for a quarantine. */
val subject: String,
/**
* What makes this notice distinct from another about a different task.
*
* ⚠️ Carried but never shown. These are stored as a `Set<String>`, and two
* discarded edits from one run share an account, a list, a cause and a
* timestamp — so two *untitled* tasks, or two both called "Milk", encoded
* identically and one of them silently vanished. The notification counted
* two and the screen listed one. The UID is the only thing that tells them
* apart, and it is exactly what must not reach the user: opaque text chosen
* by whoever created the task.
*/
val key: String,
/** Why the edit lost. Null for a quarantine, which has no such choice behind it. */
val cause: DiscardedEdit.Cause?,
val at: Instant,
) {
enum class Kind { DISCARDED_EDIT, QUARANTINED }
}
/**
* What the last syncs destroyed or gave up on, per account, until it is read.
*
* The two kinds keep different company, which is why they are written
* differently:
*
* - A **discarded edit** is news. It happened once, it cannot be undone, and a
* later clean sync does not make it untrue — so it accumulates and is cleared
* only by the user acknowledging it. Replacing the set every run would let a
* quiet sync an hour later erase the one thing worth saying.
* - A **quarantined resource** is a standing condition: one task has stopped
* syncing while the rest of its list is fine. It is re-reported on every run
* for as long as it holds, so the account's set of them is *replaced* each
* time — which is also how it clears itself the moment the resource starts
* working again.
*
* Lives with the other per-device sync state, and is therefore excluded from
* backup — see [SyncStateDataStore]. Correct on its own terms too: a restored
* device has not discarded anything.
*/
@Singleton
class SyncNoticeStore @Inject constructor(
@SyncStateDataStore private val dataStore: DataStore<Preferences>,
) {
/** Observed, so a background sync's news reaches a screen that is already open. */
fun observeAll(): Flow<List<SyncNotice>> = dataStore.data.map { prefs ->
prefs[KEY].orEmpty().mapNotNull(::decode).sortedByDescending { it.at }
}
/**
* Folds one account's run into the store.
*
* @return only what is **new**, which is what a notification may be posted
* for. A quarantine already on record is a condition the user has already
* been told about, and re-announcing it on every four-hour run would train
* them to ignore the one that matters.
*/
/**
* @param titles the local title of each quarantined resource, by href.
* ⚠️ Not optional decoration. Without it the row read
* `a1f9c3e2-….ics`, which is the opaque blob this file refuses to show
* for a discarded edit — and "a task has stopped syncing" that does not
* say which task is the very failure the feature exists to fix. Absent
* only for a resource we never stored, where the filename is genuinely
* all there is.
*/
suspend fun record(
accountId: Long,
at: Instant,
reports: List<SyncReport>,
titles: Map<String, String> = emptyMap(),
): List<SyncNotice> {
val discarded = reports.flatMap { report ->
report.discardedEdits.map { edit ->
SyncNotice(
accountId = accountId,
listName = report.listName,
kind = SyncNotice.Kind.DISCARDED_EDIT,
// The UID is not shown to anyone: it is opaque text chosen by
// whoever created the task, routinely a bare hex blob.
subject = edit.title.orEmpty(),
key = edit.uid,
cause = edit.cause,
at = at,
)
}
}
// ⚠️ Only the ones that have actually stopped. Below the threshold the
// resource is still being retried, and "one of your tasks has stopped
// syncing" would be untrue of a single 502 from a proxy mid-restart.
val quarantined = reports.flatMap { report ->
report.quarantined
.filter { it.failures >= QuarantineStore.THRESHOLD }
.map { resource ->
SyncNotice(
accountId = accountId,
listName = report.listName,
kind = SyncNotice.Kind.QUARANTINED,
subject = titles[resource.href] ?: resource.href.substringAfterLast('/'),
key = resource.href,
cause = null,
at = at,
)
}
}
// ⚠️ Nothing to say is the overwhelmingly common case — most syncs
// discard nothing and quarantine nothing — and a DataStore edit rewrites
// and fsyncs the whole file. Skipped only when there is also nothing on
// record to clear, or a recovered resource would keep its notice for ever.
if (discarded.isEmpty() && quarantined.isEmpty() && !hasRecord(accountId)) {
return emptyList()
}
var added = emptyList<SyncNotice>()
dataStore.edit { prefs ->
// ⚠️ Re-read inside `edit`, which DataStore serialises. The set is
// global while `SyncWorker`'s uniqueness is only per account, so two
// accounts can be folding in at once and a snapshot taken outside
// would discard the other's.
val current = prefs[KEY].orEmpty().mapNotNull(::decode)
val others = current.filter { it.accountId != accountId }
val keptDiscards = current.filter {
it.accountId == accountId && it.kind == SyncNotice.Kind.DISCARDED_EDIT
}
val standing = current.filter {
it.accountId == accountId && it.kind == SyncNotice.Kind.QUARANTINED
}
added = discarded + quarantined.filterNot { fresh ->
standing.any { it.key == fresh.key }
}
// Newest first, then capped: an account that has been failing for a
// week must not grow this without bound, and the oldest news is the
// least actionable.
val kept = (discarded + keptDiscards).sortedByDescending { it.at }.take(MAX_PER_ACCOUNT)
// ⚠️ Capped as well, and the class doc used to claim it did not need
// to be. "Bounded by the collection" is only true of a healthy one:
// a server answering 415 to four hundred resources puts four hundred
// entries in one preference key, rewritten on every run — and the
// account screen renders them into a plain scrolling column.
val standingNow = quarantined.take(MAX_PER_ACCOUNT)
val updated = (others + kept + standingNow).map(::encode).toSet()
// ⚠️ Only when it differs. A DataStore edit rewrites and fsyncs the
// whole file, and an account holding one un-dismissed notice would
// otherwise pay that on every four-hour sync until the user tapped
// "Got it" — which is the cost the fast path above claims to avoid.
if (updated != prefs[KEY]) prefs[KEY] = updated
}
return added
}
private suspend fun hasRecord(accountId: Long): Boolean =
dataStore.data.first().let { prefs ->
prefs[KEY].orEmpty().mapNotNull(::decode).any { it.accountId == accountId }
}
/**
* Forgets one list's notices, for a list that has just been deleted.
*
* By name, because that is how they are keyed — there is no list id in a
* notice, and by the time this is called the row it would have named is
* already gone.
*/
suspend fun forgetList(accountId: Long, listName: String) {
dataStore.edit { prefs ->
val kept = prefs[KEY].orEmpty()
.mapNotNull(::decode)
.filterNot { it.accountId == accountId && it.listName == listName }
.map(::encode)
.toSet()
if (kept != prefs[KEY]) prefs[KEY] = kept
}
}
/** Drops one quarantine notice, for a resource the user has asked to retry. */
suspend fun forgetQuarantined(accountId: Long, key: String) {
dataStore.edit { prefs ->
val kept = prefs[KEY].orEmpty()
.mapNotNull(::decode)
.filterNot {
it.accountId == accountId && it.kind == SyncNotice.Kind.QUARANTINED && it.key == key
}
.map(::encode)
.toSet()
if (kept != prefs[KEY]) prefs[KEY] = kept
}
}
/** The user has read them. */
suspend fun dismiss(accountId: Long) {
dataStore.edit { prefs ->
prefs[KEY] = prefs[KEY].orEmpty()
.mapNotNull(::decode)
.filterNot { it.accountId == accountId }
.map(::encode)
.toSet()
}
}
/**
* ⚠️ Base64 around the free text, not a delimiter and a hope. A list is
* named by its owner and a task is titled by its author, so both can hold
* any character at all — including whatever separator looked safe.
*/
private fun encode(notice: SyncNotice): String = listOf(
notice.accountId.toString(),
notice.kind.name,
notice.cause?.name.orEmpty(),
notice.at.toEpochMilliseconds().toString(),
base64(notice.listName),
base64(notice.subject),
base64(notice.key),
).joinToString(SEPARATOR)
private fun decode(entry: String): SyncNotice? {
val parts = entry.split(SEPARATOR)
if (parts.size != FIELDS) return null
val accountId = parts[0].toLongOrNull() ?: return null
val kind = SyncNotice.Kind.entries.firstOrNull { it.name == parts[1] } ?: return null
val at = parts[3].toLongOrNull() ?: return null
return SyncNotice(
accountId = accountId,
listName = unBase64(parts[4]) ?: return null,
kind = kind,
subject = unBase64(parts[5]) ?: return null,
key = unBase64(parts[6]) ?: return null,
cause = DiscardedEdit.Cause.entries.firstOrNull { it.name == parts[2] },
at = Instant.fromEpochMilliseconds(at),
)
}
private fun base64(value: String): String =
Base64.getUrlEncoder().encodeToString(value.toByteArray(Charsets.UTF_8))
private fun unBase64(value: String): String? = runCatching {
String(Base64.getUrlDecoder().decode(value), Charsets.UTF_8)
}.getOrNull()
private companion object {
val KEY = stringSetPreferencesKey("sync_notices")
/** Not present in URL-safe Base64, nor in a decimal or an enum name. */
const val SEPARATOR = "|"
const val FIELDS = 7
/** Discarded edits, per account. Quarantines are bounded by the collection. */
const val MAX_PER_ACCOUNT = 50
}
}
@@ -0,0 +1,37 @@
package de.jeanlucmakiola.agendula.data.sync
import android.util.Log
import de.jeanlucmakiola.agendula.data.tasks.room.LocalWriteListener
import de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase
import javax.inject.Inject
import javax.inject.Singleton
/**
* Pushes a local edit to its account soon after it is made.
*
* Hooked into the store's own write paths rather than Room's invalidation
* tracker: sync writes the same tables, and an observer cannot tell its own
* downloads from the user's edits.
*/
@Singleton
class SyncOnEdit @Inject constructor(
private val database: TasksDatabase,
private val trigger: SyncTrigger,
) : LocalWriteListener {
override fun onWritten(listIds: Set<Long>) {
try {
listIds.mapNotNull { database.taskLists().entity(it)?.accountId }
.toSet()
.mapNotNull { database.accounts().account(it)?.displayName }
.forEach(trigger::pushSoon)
} catch (e: Exception) {
// The edit is saved either way; the schedule picks it up later.
Log.w(TAG, "could not schedule a push", e)
}
}
private companion object {
const val TAG = "SyncOnEdit"
}
}
@@ -0,0 +1,27 @@
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
/**
* The end of [SyncTrigger.pushSoon]'s debounce: hands the account to a real
* sync. Instant, so the REPLACE that restarts the debounce only ever cancels a
* timer.
*/
@HiltWorker
class SyncPushWorker @AssistedInject constructor(
@Assisted context: Context,
@Assisted parameters: WorkerParameters,
private val trigger: SyncTrigger,
) : CoroutineWorker(context, parameters) {
override suspend fun doWork(): Result {
val accountName = inputData.getString(SyncWorker.KEY_ACCOUNT_NAME) ?: return Result.failure()
trigger.enqueueAfterRunning(accountName)
return Result.success()
}
}
@@ -0,0 +1,105 @@
package de.jeanlucmakiola.agendula.data.sync
import de.jeanlucmakiola.caldav.PushSupport
/**
* What a sync did, and — the part that matters — what it destroyed.
*
* ⚠️ Conflict policy is **server wins, local edit discarded**.
* That policy terminates, which is why it was chosen over forking under a new
* UID, but on its own it is indistinguishable from data loss: the user's edit is
* gone and nothing said so. The report is the other half of the decision, not a
* nice-to-have — [discardedEdits] is why this type exists.
*/
data class SyncReport(
val listId: Long,
val listName: String,
val downloaded: Int = 0,
val uploaded: Int = 0,
val deletedRemotely: Int = 0,
val deletedLocally: Int = 0,
/** Local edits thrown away because the server's copy was newer. */
val discardedEdits: List<DiscardedEdit> = emptyList(),
/** Resources the collection gave up on, so the rest of it could finish. */
val quarantined: List<QuarantinedResource> = emptyList(),
/**
* Writes sent without `If-Match` because the server offers no usable
* validator. Not an error, but the one case where a concurrent edit can be
* overwritten without us noticing, so it is said out loud.
*/
val unconditionalWrites: Int = 0,
/**
* Whether this run reconciled against a full listing rather than a change log.
*
* ⚠️ Tracked because a token the server accepts over a change log it has
* already pruned returns 207, zero changes and no error — RFC 6578 gives no
* signal for it at all. The only mitigation is to reconcile in full on a slow
* cadence regardless of the token, which means knowing when we last did.
*/
val reconciledInFull: Boolean = false,
/**
* Why the change-log path was abandoned, on a run the full path then
* completed.
*
* Not a [failure]: the collection is reconciled and the user has nothing to
* act on. Kept because a server that rejects `sync-collection` every time
* will do it again, and that is worth seeing in a log without it becoming an
* error in the UI.
*/
val incrementalNote: String? = null,
/**
* The server refused our credentials.
*
* ⚠️ Escalates to the whole account and stops it, unlike every other failure
* here. Nextcloud's brute-force protection throttles and then **429s per
* source IP**, so a client that keeps retrying a dead app password on a timer
* takes the user's *other* Nextcloud clients down with it, on that network,
* and looks from the outside like we broke their server. There is nothing to
* retry anyway: only the user can fix it.
*/
val authFailure: Boolean = false,
/** Set when the collection failed as a whole. The account keeps going. */
val failure: String? = null,
/**
* The collection's own properties were read this run, so [pushSupport] is
* the server's answer rather than the absence of one.
*/
val collectionRead: Boolean = false,
/** WebDAV-Push, as the collection offered it this run. */
val pushSupport: PushSupport? = null,
) {
val hadWork: Boolean
get() = downloaded > 0 || uploaded > 0 || deletedRemotely > 0 || deletedLocally > 0
}
/** One local edit that lost to the server. */
data class DiscardedEdit(
val uid: String,
val title: String?,
val cause: Cause,
) {
enum class Cause {
/** The server's copy changed after we last read it. */
SERVER_NEWER,
/** The task was deleted on the server while it was edited here. */
DELETED_ON_SERVER,
/** Deleted here, but changed on the server after that. The delete lost. */
DELETE_LOST,
}
}
/**
* A resource the collection stopped trying.
*
* ⚠️ Quarantine is a **counter, not a backoff**. A single HTTP 400 on one
* resource has halted all of a user's calendar sync in DAVx5 for weeks; the
* failure has to be contained to the resource that caused it, and the rest of
* the collection has to complete.
*/
data class QuarantinedResource(
val href: String,
val reason: String,
val failures: Int,
)
@@ -0,0 +1,88 @@
package de.jeanlucmakiola.agendula.data.sync
import de.jeanlucmakiola.agendula.data.tasks.room.TaskEntity
import de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase
import javax.inject.Inject
/**
* The database, as [CollectionSyncer] needs it.
*
* A seam, and the reason is the same one that put [CalDavGateway] in front of
* discovery: the reconciliation above this interface is where local edits are
* discarded, tombstones swept and conflicts resolved, and every one of those is
* a decision that should be provable without a device. Room's test double is
* Robolectric plus an in-memory database; this is eight methods.
*/
interface SyncStore {
/** Every row in a list, **tombstones included**. */
fun rowsIn(listId: Long): List<TaskEntity>
fun insert(row: TaskEntity): Long
fun update(row: TaskEntity)
fun deleteAll(taskIds: List<Long>)
/**
* Records href and ETag on a resource's rows, clearing `is_dirty`.
*
* The caller excludes any row it left out of the body — that row is deleted,
* not marked synced.
*/
fun markSynced(taskIds: List<Long>, href: String?, eTag: String?)
fun setParent(taskId: Long, parentId: Long?)
/** The master row for a UID — the one with no `RECURRENCE-ID`. */
fun masterByUid(listId: Long, uid: String): TaskEntity?
fun row(taskId: Long): TaskEntity?
/**
* One column, deliberately. A whole-entity update would carry the row as it
* looked when the sync started and revert anything the user changed while it
* ran.
*/
fun setListReadOnly(listId: Long, readOnly: Boolean)
/** The RFC 6578 cursor. Null resets the collection to a full reconciliation. */
fun setSyncToken(listId: Long, token: String?)
}
class RoomSyncStore @Inject constructor(
private val database: TasksDatabase,
) : SyncStore {
override fun rowsIn(listId: Long) = database.tasks().allIn(listId)
override fun insert(row: TaskEntity) = database.tasks().insert(row)
override fun update(row: TaskEntity) {
database.tasks().update(row)
}
override fun deleteAll(taskIds: List<Long>) {
if (taskIds.isNotEmpty()) database.tasks().deleteAll(taskIds)
}
override fun markSynced(taskIds: List<Long>, href: String?, eTag: String?) {
if (taskIds.isNotEmpty()) database.tasks().markSynced(taskIds, href, eTag)
}
override fun setParent(taskId: Long, parentId: Long?) {
database.tasks().setParent(taskId, parentId)
}
override fun masterByUid(listId: Long, uid: String) = database.tasks().byUid(listId, uid)
override fun row(taskId: Long) = database.tasks().entity(taskId)
override fun setListReadOnly(listId: Long, readOnly: Boolean) {
database.taskLists().setReadOnly(listId, readOnly)
}
override fun setSyncToken(listId: Long, token: String?) {
database.taskLists().setSyncToken(listId, token)
}
}
@@ -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. 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.
*
* 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,205 @@
package de.jeanlucmakiola.agendula.data.sync
import android.content.Context
import androidx.work.Constraints
import androidx.work.Data
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.ExistingWorkPolicy
import androidx.work.NetworkType
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.OutOfQuotaPolicy
import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkInfo
import androidx.work.WorkManager
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import java.util.concurrent.TimeUnit
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.time.Duration
import kotlin.time.Duration.Companion.hours
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Duration.Companion.seconds
/**
* Starts a sync for one account.
*
* Shared by the sync adapter and by the app's own "sync now", because the app
* cannot rely on the system trigger: ⚠️ `ContentService.hasAuthorityAccess()`
* gates `requestSync` behind a compat change that is on at targetSdk ≥ 34, and
* our authority is `userVisible="false"`, so Settings greys "Sync now" out. The
* in-app button enqueues the work directly and is unaffected.
*/
@Singleton
class SyncTrigger @Inject constructor(
@ApplicationContext private val context: Context,
) : SyncRequests {
override fun syncNow(accountName: String) {
enqueue(accountName)
}
/**
* Starts a sync now.
*
* @param expedited for a trigger the user is looking at. ⚠️ Paired with
* `RUN_AS_NON_EXPEDITED_WORK_REQUEST`, which is not optional: the expedited
* quota is per-app and exhaustible, and the alternative policy
* (`DROP_WORK_REQUEST`) silently discards the sync the user just asked for.
* Never set from a background trigger — a boot receiver spending the quota
* leaves none for the button.
* @return the unique work name, which the caller may wait on.
*/
fun enqueue(accountName: String, expedited: Boolean = false): String {
val uniqueName = SyncWorker.uniqueNameFor(accountName)
val request = OneTimeWorkRequestBuilder<SyncWorker>()
.setInputData(inputFor(accountName))
.setConstraints(NETWORK)
.apply {
if (expedited) setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)
}
.build()
WorkManager.getInstance(context).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,
)
return uniqueName
}
/**
* Pushes a local edit soon, rather than on the next periodic window.
*
* Debounced: each call restarts [PUSH_DELAY], so a burst of edits costs one
* sync. What waits out the delay is a [SyncPushWorker], which hands over to
* [enqueueAfterRunning] — so the replace can only ever cancel a timer, never
* a sync that is mid-flight.
*/
fun pushSoon(accountName: String) {
val request = OneTimeWorkRequestBuilder<SyncPushWorker>()
.setInputData(inputFor(accountName))
.setInitialDelay(PUSH_DELAY.inWholeSeconds, TimeUnit.SECONDS)
.build()
WorkManager.getInstance(context).enqueueUniqueWork(
pushNameFor(accountName),
ExistingWorkPolicy.REPLACE,
request,
)
}
/**
* A sync that runs after any one already running for [accountName].
*
* Not [enqueue]'s KEEP: a sync already past its upload phase would swallow
* this request and leave the edit that prompted it for the next window.
*/
fun enqueueAfterRunning(accountName: String) {
val request = OneTimeWorkRequestBuilder<SyncWorker>()
.setInputData(inputFor(accountName))
.setConstraints(NETWORK)
.build()
WorkManager.getInstance(context).enqueueUniqueWork(
SyncWorker.uniqueNameFor(accountName),
ExistingWorkPolicy.APPEND_OR_REPLACE,
request,
)
}
/**
* A sync for a push message: the server says something changed.
*
* Appended like [enqueueAfterRunning], since a sync already past its
* download would miss the change — but only once. A burst of pushes during
* one sync must cost one more sync, not one each.
*/
suspend fun enqueueFromPush(accountName: String) = pushEnqueue.withLock {
val waiting = WorkManager.getInstance(context)
.getWorkInfosForUniqueWorkFlow(SyncWorker.uniqueNameFor(accountName))
.first()
.any { it.state == WorkInfo.State.ENQUEUED || it.state == WorkInfo.State.BLOCKED }
if (!waiting) enqueueAfterRunning(accountName)
}
/** Serialises [enqueueFromPush]'s check and its enqueue across concurrent messages. */
private val pushEnqueue = Mutex()
/**
* Puts the account on the periodic schedule.
*
* A plain `PeriodicWorkRequest` and no foreground service, deliberately —
* see [SyncWorker]. WorkManager restores its own schedule after a reboot, so
* nothing has to re-arm this from `BOOT_COMPLETED`; that matters because
* Android 15 forbids starting a `dataSync` foreground service from boot, and
* a design that needed one would have no way to run at all.
*
* ⚠️ Be honest about the cadence in the UI. The interval setting is a
* floor, not a promise: in the `rare` and `restricted` App Standby buckets
* network access is off entirely, and the genuine worst case is once overnight.
*
* @param intervalMinutes the sync-interval setting; 0 (manual only) takes
* the account off the schedule.
* @param intervalChanged the user just picked a new interval, so a schedule
* already in place is replaced rather than kept.
*/
fun schedule(accountName: String, intervalMinutes: Int, intervalChanged: Boolean = false) {
val minutes = intervalMinutes
if (minutes <= 0) {
WorkManager.getInstance(context).cancelUniqueWork(periodicNameFor(accountName))
return
}
val request = PeriodicWorkRequestBuilder<SyncWorker>(
minutes.toLong(), TimeUnit.MINUTES,
flexFor(minutes).inWholeMinutes, TimeUnit.MINUTES,
)
.setInputData(inputFor(accountName))
.setConstraints(NETWORK)
.build()
WorkManager.getInstance(context).enqueueUniquePeriodicWork(
periodicNameFor(accountName),
// UPDATE on every call would restart the interval on every app
// launch, so a device that is opened often would never reach the
// end of one. Only a changed interval replaces it.
if (intervalChanged) ExistingPeriodicWorkPolicy.UPDATE else ExistingPeriodicWorkPolicy.KEEP,
request,
)
}
/** Takes a removed account off the schedule. */
fun cancel(accountName: String) {
WorkManager.getInstance(context).apply {
cancelUniqueWork(periodicNameFor(accountName))
cancelUniqueWork(pushNameFor(accountName))
cancelUniqueWork(SyncWorker.uniqueNameFor(accountName))
}
}
private fun inputFor(accountName: String) =
Data.Builder().putString(SyncWorker.KEY_ACCOUNT_NAME, accountName).build()
private companion object {
/** How long a local edit waits for more before it is pushed. */
val PUSH_DELAY: Duration = 30.seconds
/** The tail of each interval the system may run us in: a quarter of it, at most an hour. */
fun flexFor(intervalMinutes: Int): Duration =
(intervalMinutes / 4).minutes.coerceIn(5.minutes, 1.hours)
/**
* Sync needs a network, and saying so lets WorkManager run us the moment
* connectivity returns rather than on the next interval.
*/
val NETWORK: Constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()
fun periodicNameFor(accountName: String) = "caldav-sync-periodic:$accountName"
fun pushNameFor(accountName: String) = "caldav-sync-push:$accountName"
}
}
@@ -0,0 +1,133 @@
package de.jeanlucmakiola.agendula.data.sync
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.content.Context
import android.util.Log
import androidx.core.app.NotificationCompat
import androidx.hilt.work.HiltWorker
import androidx.work.CoroutineWorker
import androidx.work.ForegroundInfo
import androidx.work.WorkerParameters
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import de.jeanlucmakiola.agendula.R
import de.jeanlucmakiola.agendula.data.reminders.ReminderScheduler
/**
* Where sync actually happens.
*
* Two things about this worker are decided already. 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,
private val engine: SyncEngine,
private val noticeNotifier: SyncNoticeNotifier,
private val reminderScheduler: ReminderScheduler,
private val accountState: AccountStateStore,
) : CoroutineWorker(context, parameters) {
override suspend fun doWork(): Result {
val accountName = inputData.getString(KEY_ACCOUNT_NAME) ?: return Result.failure()
return when (val outcome = engine.sync(accountName)) {
is SyncEngine.Result.Synced -> {
// ⚠️ Success even when collections failed. A retry re-runs the
// whole account, and WorkManager's backoff would then punish the
// four healthy collections for the one that 500s — while the
// failing one is already contained by its own quarantine counter.
outcome.reports.forEach { report ->
if (report.failure != null || report.hadWork) Log.i(TAG, report.toString())
}
// ⚠️ Said out loud, not only logged. `SyncReport`'s own doc
// calls the report "the other half" of server-wins, and this
// worker runs on a four-hour timer with the app closed — so a
// log line is the same as saying nothing. Only what is new: the
// store has already dropped whatever the user has been told.
noticeNotifier.post(accountName, outcome.notices)
// Nothing else re-arms reminders for what a sync pulled in: in our
// own store no provider broadcast fires.
if (outcome.reports.any { it.hadWork || it.discardedEdits.isNotEmpty() }) {
runCatching { reminderScheduler.sync() }
}
noticeNotifier.cancelSignIn(accountName)
Result.success()
}
// Only the user can fix this, and retrying costs them Nextcloud's
// per-IP brute-force throttle — which takes their *other* clients
// down with it. Said once per stop, since this runs with the app closed.
is SyncEngine.Result.NeedsSignIn -> {
if (accountState.markSignInNotified(outcome.accountId)) {
noticeNotifier.postSignIn(accountName, outcome.accountId)
}
Result.failure()
}
is SyncEngine.Result.Misconfigured -> Result.failure()
// Not a failure: the account is kept, and syncs again once the user
// switches back to Agendula's own storage.
SyncEngine.Result.Paused -> Result.success()
}
}
/**
* ⚠️ Implemented **unconditionally**, even though this worker never asks to
* run in the foreground.
*
* `setExpedited` falls back to a foreground service below API 31, and
* WorkManager calls this to build it. The default implementation throws
* `IllegalStateException`, so a worker that only ever runs expedited on
* modern devices crashes on every device running API 29 or 30 — which we
* support. It is never actually shown above API 30.
*/
override suspend fun getForegroundInfo(): ForegroundInfo {
val manager = applicationContext.getSystemService(NotificationManager::class.java)
manager?.createNotificationChannel(
NotificationChannel(
CHANNEL_ID,
applicationContext.getString(R.string.sync_notification_channel),
NotificationManager.IMPORTANCE_LOW,
),
)
val notification: Notification = NotificationCompat.Builder(applicationContext, CHANNEL_ID)
.setContentTitle(applicationContext.getString(R.string.sync_notification_title))
.setSmallIcon(R.drawable.ic_notification)
.setOngoing(true)
.setPriority(NotificationCompat.PRIORITY_LOW)
.build()
// ⚠️ **No `foregroundServiceType`.** Declaring `dataSync` is what drags in
// `FOREGROUND_SERVICE_DATA_SYNC`, the Android 15 six-hours-per-24 budget
// whose failure mode is a fatal `RemoteServiceException`, and a Play
// requirement for a video demo per declared type — the whole tail this
// worker exists to avoid. It is not needed either: above API 30
// `setExpedited` uses an expedited job and never calls this at all, and
// types only became mandatory at API 34.
return ForegroundInfo(NOTIFICATION_ID, notification)
}
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"
private const val TAG = "SyncWorker"
private const val CHANNEL_ID = "sync"
private const val NOTIFICATION_ID = 4001
}
}
@@ -0,0 +1,60 @@
package de.jeanlucmakiola.agendula.data.sync.push
import android.util.Log
import dagger.hilt.android.AndroidEntryPoint
import de.jeanlucmakiola.agendula.data.di.ApplicationScope
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import org.unifiedpush.android.connector.FailedReason
import org.unifiedpush.android.connector.PushService
import org.unifiedpush.android.connector.data.PushEndpoint
import org.unifiedpush.android.connector.data.PushMessage
import javax.inject.Inject
/**
* Where the UnifiedPush distributor reaches us; the instance is the account id.
* Work runs in the application scope, since the connector unbinds after a second.
*/
@AndroidEntryPoint
class AgendulaPushService : PushService() {
@Inject @ApplicationScope lateinit var scope: CoroutineScope
@Inject lateinit var registrar: PushRegistrar
@Inject lateinit var messages: PushMessageHandler
override fun onNewEndpoint(endpoint: PushEndpoint, instance: String) {
val accountId = instance.toLongOrNull() ?: return
scope.launch { runCatching { registrar.onNewEndpoint(accountId, endpoint) }.onFailure(::log) }
}
override fun onMessage(message: PushMessage, instance: String) {
// Encryption is mandatory in the draft.
if (!message.decrypted) {
Log.w(TAG, "dropped a push message that did not decrypt")
return
}
val content = message.content.toString(Charsets.UTF_8)
scope.launch { runCatching { messages.handle(content, instance) }.onFailure(::log) }
}
override fun onRegistrationFailed(reason: FailedReason, instance: String) {
Log.w(TAG, "distributor refused registration for $instance: $reason")
// A transient failure leaves the last endpoint valid; the next renewal retries.
if (reason == FailedReason.NETWORK || reason == FailedReason.INTERNAL_ERROR) return
val accountId = instance.toLongOrNull() ?: return
scope.launch { runCatching { registrar.onUnregistered(accountId) }.onFailure(::log) }
}
override fun onUnregistered(instance: String) {
val accountId = instance.toLongOrNull() ?: return
scope.launch { runCatching { registrar.onUnregistered(accountId) }.onFailure(::log) }
}
private fun log(error: Throwable) = Log.w(TAG, "push handling failed", error)
private companion object {
const val TAG = "AgendulaPushService"
}
}
@@ -0,0 +1,79 @@
package de.jeanlucmakiola.agendula.data.sync.push
import android.content.Context
import android.content.pm.PackageManager
import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.first
import org.unifiedpush.android.connector.UnifiedPush
import org.unifiedpush.android.connector.data.ResolvedDistributor
import javax.inject.Inject
import javax.inject.Singleton
/**
* Which UnifiedPush distributor delivers our pushes, and whether push is on. The
* connector keeps the choice; the on/off switch lives in [SettingsPrefs].
*/
@Singleton
class PushDistributors @Inject constructor(
@ApplicationContext private val context: Context,
private val settings: SettingsPrefs,
) {
data class Distributor(val packageName: String, val label: String)
/** The distributor apps installed right now. */
fun installed(): List<Distributor> =
UnifiedPush.getDistributors(context)
.filter { it != context.packageName }
.map { Distributor(it, labelOf(it)) }
.sortedBy { it.label.lowercase() }
/**
* The distributor to register with, or null when push is off or none is
* usable. With no choice saved, a default or sole distributor is adopted.
*/
suspend fun toUse(): String? {
if (!settings.settings.first().pushEnabled) return null
UnifiedPush.getSavedDistributor(context)?.let { return it }
return when (val resolved = UnifiedPush.resolveDefaultDistributor(context)) {
is ResolvedDistributor.Found -> resolved.packageName
.takeIf { it != context.packageName }
?.also { UnifiedPush.saveDistributor(context, it) }
ResolvedDistributor.ToSelect, ResolvedDistributor.NoneAvailable -> null
}
}
/** The saved choice, without resolving a default. For display. */
fun saved(): String? = UnifiedPush.getSavedDistributor(context)
suspend fun select(packageName: String) {
UnifiedPush.saveDistributor(context, packageName)
settings.setPushEnabled(true)
}
/** Turns push off. Every registration with the distributor goes with it. */
suspend fun disable() {
settings.setPushEnabled(false)
UnifiedPush.removeDistributor(context)
}
/**
* Unregisters one instance without losing the user's choice, which the
* connector drops along with its last instance.
*/
fun unregister(instance: String) {
val chosen = UnifiedPush.getSavedDistributor(context)
UnifiedPush.unregister(context, instance)
if (chosen != null && UnifiedPush.getSavedDistributor(context) == null) {
UnifiedPush.saveDistributor(context, chosen)
}
}
fun labelOf(packageName: String): String = try {
val info = context.packageManager.getApplicationInfo(packageName, 0)
context.packageManager.getApplicationLabel(info).toString()
} catch (_: PackageManager.NameNotFoundException) {
packageName
}
}
@@ -0,0 +1,53 @@
package de.jeanlucmakiola.agendula.data.sync.push
import android.util.Log
import de.jeanlucmakiola.agendula.data.di.IoDispatcher
import de.jeanlucmakiola.agendula.data.sync.SyncTrigger
import de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase
import de.jeanlucmakiola.caldav.WebDavPush
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.withContext
import javax.inject.Inject
import javax.inject.Singleton
/** Turns a WebDAV-Push message into a sync of the account it is about. */
@Singleton
class PushMessageHandler @Inject constructor(
private val database: TasksDatabase,
private val store: PushStore,
private val trigger: SyncTrigger,
@IoDispatcher private val io: CoroutineDispatcher,
) {
/**
* @param content the decrypted message body.
* @param instance the UnifiedPush instance, which is the account id.
*/
suspend fun handle(content: String, instance: String) = withContext(io) {
val accountId = instance.toLongOrNull() ?: return@withContext
val account = database.accounts().account(accountId) ?: return@withContext
val message = WebDavPush.parse(content)
val topic = message?.topic
if (topic != null) {
// Within this account: a shared calendar has one topic across accounts.
val list = store.all().values
.filter { it.support?.topic == topic }
.firstNotNullOfOrNull { push ->
database.taskLists().entity(push.listId)?.takeIf { it.accountId == accountId }
}
if (list == null) {
Log.i(TAG, "push for a topic no synced list has")
return@withContext
}
// Already at that state, e.g. our own write echoed back.
if (message.syncToken != null && message.syncToken == list.syncToken) return@withContext
}
// Without a topic (key rotation, unreadable): a sync re-reads the VAPID key.
trigger.enqueueFromPush(account.displayName)
}
private companion object {
const val TAG = "PushMessageHandler"
}
}
@@ -0,0 +1,271 @@
package de.jeanlucmakiola.agendula.data.sync.push
import android.content.Context
import android.util.Log
import androidx.work.BackoffPolicy
import androidx.work.Constraints
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.NetworkType
import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager
import dagger.hilt.android.qualifiers.ApplicationContext
import de.jeanlucmakiola.agendula.data.di.IoDispatcher
import de.jeanlucmakiola.agendula.data.sync.AccountStateStore
import de.jeanlucmakiola.agendula.data.sync.CredentialStore
import de.jeanlucmakiola.agendula.data.sync.SyncAvailability
import de.jeanlucmakiola.agendula.data.sync.SyncReport
import de.jeanlucmakiola.agendula.data.tasks.room.AccountEntity
import de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase
import de.jeanlucmakiola.caldav.CalDavHttp
import de.jeanlucmakiola.caldav.WebDavPush
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.OkHttpClient
import org.unifiedpush.android.connector.UnifiedPush
import org.unifiedpush.android.connector.data.PushEndpoint
import java.util.concurrent.TimeUnit
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.time.Clock
import kotlin.time.Duration.Companion.days
/**
* Keeps WebDAV-Push subscriptions in step with the synced lists, modelled on
* DAVx5's `PushRegistrationManager`. [update] registers each account with the
* distributor; [onNewEndpoint] subscribes the endpoint it answers with. The
* daily [PushRenewalWorker] re-registers, which is what renews subscriptions.
*/
@Singleton
class PushRegistrar @Inject constructor(
@ApplicationContext private val context: Context,
private val database: TasksDatabase,
private val store: PushStore,
private val distributors: PushDistributors,
private val credentials: CredentialStore,
private val accountState: AccountStateStore,
private val availability: SyncAvailability,
@IoDispatcher private val io: CoroutineDispatcher,
) {
/** One subscribe/unsubscribe pass at a time, across every entry point. */
private val mutex = Mutex()
/** Records what a sync read about push support, and registers if that changed. */
suspend fun onSynced(account: AccountEntity, reports: List<SyncReport>) {
val read = reports.filter { it.collectionRead }.associate { it.listId to it.pushSupport }
if (store.recordSupport(read)) update(account.id)
}
suspend fun updateAll() = mutex.withLock {
withContext(io) {
database.accounts().all().forEach { updateAccount(it) }
scheduleRenewal()
}
}
suspend fun update(accountId: Long) = mutex.withLock {
withContext(io) {
database.accounts().account(accountId)?.let { updateAccount(it) }
scheduleRenewal()
}
}
/** Our subscription per collection URL, for `Push-Dont-Notify` on the account's writes. */
suspend fun subscriptionsByHref(accountId: Long): Map<String, HttpUrl> = withContext(io) {
val pushes = store.all()
database.taskLists().syncedForAccount(accountId).mapNotNull { list ->
// Normalised the way SyncEngine spells the URL it looks up.
val href = list.href?.toHttpUrlOrNull()?.toString() ?: return@mapNotNull null
val subscription = pushes[list.id]?.subscription?.toHttpUrlOrNull() ?: return@mapNotNull null
href to subscription
}.toMap()
}
/** The distributor's endpoint for [accountId] is ready: subscribe the account's lists to it. */
suspend fun onNewEndpoint(accountId: Long, endpoint: PushEndpoint) = mutex.withLock {
withContext(io) {
val account = database.accounts().account(accountId) ?: return@withContext
if (!pushAllowed(account)) return@withContext
val client = clientFor(account) ?: return@withContext
val pushes = store.all()
val lists = database.taskLists().syncedForAccount(account.id)
val wanted = lists.filter { it.href != null && pushes[it.id]?.support != null }
val renewBefore = Clock.System.now() + RENEW_MARGIN
for (list in wanted) {
val push = pushes.getValue(list.id)
val current = push.subscription != null &&
push.endpoint == endpoint.url &&
push.expires?.let { it > renewBefore } == true
if (current) continue
// A changed endpoint means a new subscription, not an update.
if (push.endpoint != null && push.endpoint != endpoint.url) {
push.subscription?.toHttpUrlOrNull()?.let { WebDavPush.unregister(client, it) }
}
val collection = list.href!!.toHttpUrlOrNull() ?: continue
when (
val outcome = WebDavPush.register(
client = client,
collection = collection,
endpoint = endpoint.url,
publicKey = endpoint.pubKeySet?.pubKey,
authSecret = endpoint.pubKeySet?.auth,
expires = Clock.System.now() + REQUESTED_LIFETIME,
)
) {
is WebDavPush.Registration.Registered -> store.recordSubscription(
listId = list.id,
subscription = outcome.url?.toString(),
endpoint = endpoint.url,
expires = outcome.expires,
)
is WebDavPush.Registration.Refused -> {
Log.w(TAG, "push refused for ${list.id}: HTTP ${outcome.code}")
store.clearSubscription(list.id)
// Stop the account as a sync would; Nextcloud throttles per IP.
if (outcome.code == UNAUTHORIZED) {
accountState.setNeedsSignIn(account.id, true)
return@withContext
}
}
// Retried by the next renewal.
is WebDavPush.Registration.Failed -> Log.w(TAG, "push registration failed for ${list.id}: ${outcome.reason}")
}
}
// A list that lost push support still holds a subscription nobody wants.
val wantedIds = wanted.map { it.id }.toSet()
val stale = lists.filter { it.id !in wantedIds && pushes[it.id]?.subscription != null }
unsubscribe(client, stale.map { it.id }, pushes)
}
}
/** The distributor dropped [accountId]'s registration: its subscriptions lead nowhere. */
suspend fun onUnregistered(accountId: Long) = mutex.withLock {
withContext(io) {
database.accounts().account(accountId)?.let { unsubscribeAll(it) }
}
}
/** Before an account is removed, while its credential still works. */
suspend fun forgetAccount(accountId: Long) = mutex.withLock {
withContext(io) {
val account = database.accounts().account(accountId) ?: return@withContext
unsubscribeAll(account)
distributors.unregister(accountId.toString())
store.forget(database.taskLists().syncedForAccount(accountId).map { it.id }.toSet())
}
}
/** Before lists stop syncing with [accountId]. */
suspend fun forgetLists(accountId: Long, listIds: Set<Long>) = mutex.withLock {
withContext(io) {
if (listIds.isEmpty()) return@withContext
val account = database.accounts().account(accountId)
val client = account?.let { clientFor(it) }
val pushes = store.all()
if (client != null) unsubscribe(client, listIds.toList(), pushes)
store.forget(listIds)
}
}
private suspend fun updateAccount(account: AccountEntity) {
val instance = account.id.toString()
val distributor = if (pushAllowed(account)) distributors.toUse() else null
val pushes = store.all()
val capable = database.taskLists().syncedForAccount(account.id)
.mapNotNull { pushes[it.id]?.support }
if (distributor == null || capable.isEmpty()) {
// Not unregistered: the connector would forget the user's distributor.
unsubscribeAll(account)
return
}
val vapid = capable.firstNotNullOfOrNull { it.vapidPublicKey }
try {
UnifiedPush.register(context, instance, account.displayName, vapid)
} catch (_: UnifiedPush.VapidNotValidException) {
Log.w(TAG, "server VAPID key for ${account.id} is not usable")
UnifiedPush.register(context, instance, account.displayName, null)
}
}
private suspend fun unsubscribeAll(account: AccountEntity) {
val pushes = store.all()
val held = database.taskLists().syncedForAccount(account.id)
.filter { pushes[it.id]?.subscription != null }
.map { it.id }
if (held.isEmpty()) return
val client = clientFor(account)
if (client != null) {
unsubscribe(client, held, pushes)
} else {
// Without a credential they are left to expire.
held.forEach { store.clearSubscription(it) }
}
}
/** Tells the server, then forgets locally; after the first failure only forgets. */
private suspend fun unsubscribe(client: OkHttpClient, listIds: List<Long>, pushes: Map<Long, PushStore.ListPush>) {
var reachable = true
listIds.forEach { listId ->
val subscription = pushes[listId]?.subscription?.toHttpUrlOrNull()
if (reachable && subscription != null) reachable = WebDavPush.unregister(client, subscription)
store.clearSubscription(listId)
}
}
private suspend fun pushAllowed(account: AccountEntity): Boolean =
availability.accountsUsable() && !accountState.needsSignIn(account.id)
/** Null for an account waiting on sign-in, as in `SyncEngine.sync`. */
private suspend fun clientFor(account: AccountEntity): OkHttpClient? {
if (accountState.needsSignIn(account.id)) return null
val username = account.username ?: return null
val origin = account.principalUrl?.toHttpUrlOrNull() ?: return null
val password = (credentials.get(account.id) as? CredentialStore.Secret.Present)?.value ?: return null
return CalDavHttp.authenticated(USER_AGENT, username, password, origin)
.newBuilder()
.callTimeout(CALL_TIMEOUT_SECONDS, TimeUnit.SECONDS)
.build()
}
/** Only while some list could be pushed; nothing to renew otherwise. */
private suspend fun scheduleRenewal() {
val work = WorkManager.getInstance(context)
val needed = distributors.toUse() != null && store.all().values.any { it.support != null }
if (!needed) {
work.cancelUniqueWork(PushRenewalWorker.NAME)
return
}
val request = PeriodicWorkRequestBuilder<PushRenewalWorker>(RENEWAL_INTERVAL_DAYS, TimeUnit.DAYS)
.setConstraints(Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build())
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 1, TimeUnit.MINUTES)
.build()
work.enqueueUniquePeriodicWork(PushRenewalWorker.NAME, ExistingPeriodicWorkPolicy.KEEP, request)
}
private companion object {
const val TAG = "PushRegistrar"
const val USER_AGENT = "Agendula (Android)"
const val UNAUTHORIZED = 401
/** A registration is one small request; removal waits on it. */
const val CALL_TIMEOUT_SECONDS = 15L
/** What we ask for; the draft recommends at least three days. */
val REQUESTED_LIFETIME = 3.days
const val RENEWAL_INTERVAL_DAYS = 1L
/** Two renewal intervals, since periodic work is not punctual. */
val RENEW_MARGIN = (2 * RENEWAL_INTERVAL_DAYS).days
}
}
@@ -0,0 +1,26 @@
package de.jeanlucmakiola.agendula.data.sync.push
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
/** Re-registers every account daily, which renews the subscriptions; see [PushRegistrar]. */
@HiltWorker
class PushRenewalWorker @AssistedInject constructor(
@Assisted context: Context,
@Assisted parameters: WorkerParameters,
private val registrar: PushRegistrar,
) : CoroutineWorker(context, parameters) {
override suspend fun doWork(): Result {
registrar.updateAll()
return Result.success()
}
companion object {
const val NAME = "push-renewal"
}
}
@@ -0,0 +1,120 @@
package de.jeanlucmakiola.agendula.data.sync.push
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringSetPreferencesKey
import de.jeanlucmakiola.agendula.data.di.SyncStateDataStore
import de.jeanlucmakiola.caldav.PushSupport
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import java.net.URLDecoder
import java.net.URLEncoder
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.time.Instant
/**
* Per synced list: what its server offers for push, and our subscription there.
* Kept out of Room and backups, since a subscription names this device's endpoint.
*/
@Singleton
class PushStore @Inject constructor(
@SyncStateDataStore private val dataStore: DataStore<Preferences>,
) {
data class ListPush(
val listId: Long,
/** Null when the server offers no push for the list. */
val support: PushSupport? = null,
/** Where our subscription lives on the server; null when there is none. */
val subscription: String? = null,
/** The push endpoint [subscription] was registered for. */
val endpoint: String? = null,
val expires: Instant? = null,
)
suspend fun all(): Map<Long, ListPush> = decode(dataStore.data.first()[KEY].orEmpty())
fun observe(): Flow<Map<Long, ListPush>> = dataStore.data.map { decode(it[KEY].orEmpty()) }
/**
* Records what the last sync read about each list's push support.
*
* @return whether anything changed, which is what makes a registration due.
*/
suspend fun recordSupport(support: Map<Long, PushSupport?>): Boolean {
if (support.isEmpty()) return false
var changed = false
update { current ->
support.forEach { (listId, found) ->
val before = current[listId] ?: ListPush(listId)
if (before.support != found) {
changed = true
current[listId] = before.copy(support = found)
}
}
}
return changed
}
suspend fun recordSubscription(listId: Long, subscription: String?, endpoint: String?, expires: Instant?) =
update { current ->
val before = current[listId] ?: ListPush(listId)
current[listId] = before.copy(subscription = subscription, endpoint = endpoint, expires = expires)
}
suspend fun clearSubscription(listId: Long) = recordSubscription(listId, null, null, null)
suspend fun forget(listIds: Set<Long>) {
if (listIds.isEmpty()) return
update { current -> listIds.forEach(current::remove) }
}
private suspend fun update(change: (MutableMap<Long, ListPush>) -> Unit) {
dataStore.edit { prefs ->
// Re-read inside `edit`, which DataStore serialises.
val current = decode(prefs[KEY].orEmpty()).toMutableMap()
change(current)
prefs[KEY] = current.values
.filter { it.support != null || it.subscription != null }
.map(::encode)
.toSet()
}
}
private fun encode(push: ListPush): String = listOf(
push.listId.toString(),
push.support?.topic,
push.support?.vapidPublicKey,
push.subscription,
push.endpoint,
push.expires?.epochSeconds?.toString(),
).joinToString(SEPARATOR) { it?.let(::escape).orEmpty() }
private fun decode(entries: Set<String>): Map<Long, ListPush> =
entries.mapNotNull { entry ->
val parts = entry.split(SEPARATOR).map { part -> part.takeIf { it.isNotEmpty() }?.let(::unescape) }
if (parts.size != FIELDS) return@mapNotNull null
val listId = parts[0]?.toLongOrNull() ?: return@mapNotNull null
listId to ListPush(
listId = listId,
support = parts[1]?.let { PushSupport(topic = it, vapidPublicKey = parts[2]) },
subscription = parts[3],
endpoint = parts[4],
expires = parts[5]?.toLongOrNull()?.let(Instant::fromEpochSeconds),
)
}.toMap()
// Server-chosen strings, escaped so none contains the separator.
private fun escape(value: String): String = URLEncoder.encode(value, "UTF-8")
private fun unescape(value: String): String = URLDecoder.decode(value, "UTF-8")
private companion object {
val KEY = stringSetPreferencesKey("push_lists")
const val SEPARATOR = "|"
const val FIELDS = 6
}
}
@@ -0,0 +1,481 @@
package de.jeanlucmakiola.agendula.ui.accounts
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.List
import androidx.compose.material.icons.automirrored.rounded.Login
import androidx.compose.material.icons.rounded.Check
import androidx.compose.material.icons.rounded.Bolt
import androidx.compose.material.icons.rounded.CloudSync
import androidx.compose.material.icons.rounded.DeleteOutline
import androidx.compose.material.icons.rounded.History
import androidx.compose.material.icons.rounded.HistoryToggleOff
import androidx.compose.material.icons.rounded.Inventory2
import androidx.compose.material.icons.rounded.Refresh
import androidx.compose.material.icons.rounded.SyncProblem
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.agendula.R
import de.jeanlucmakiola.agendula.data.sync.DiscardedEdit
import de.jeanlucmakiola.agendula.data.sync.SyncNotice
import de.jeanlucmakiola.agendula.data.tasks.room.AccountEntity
import de.jeanlucmakiola.agendula.ui.accounts.add.message
import de.jeanlucmakiola.agendula.ui.common.OnResume
import de.jeanlucmakiola.floret.components.CollapsingScaffold
import de.jeanlucmakiola.floret.components.FullScreenPicker
import de.jeanlucmakiola.floret.components.GroupedListInset
import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.Position
import de.jeanlucmakiola.floret.components.SelectedCheck
import de.jeanlucmakiola.floret.components.positionOf
/**
* One account: who it is, how its last sync went, and what can be done to it.
*
* Tapping a row in the list opens this rather than a destructive prompt — a list
* row leads somewhere, it does not fire an irreversible action — so removing an
* account is a button on the account's own screen.
*/
@Composable
internal fun AccountDetailScreen(
accountId: Long,
onBack: () -> Unit,
onRemoved: () -> Unit,
onSignInAgain: (AccountEntity) -> Unit,
onOpenStorage: () -> Unit,
viewModel: AccountsViewModel,
) {
val accounts by viewModel.accounts.collectAsStateWithLifecycle()
val usable by viewModel.accountsUsable.collectAsStateWithLifecycle()
val push by viewModel.push.collectAsStateWithLifecycle()
OnResume(viewModel::refreshPush)
val row = accounts?.firstOrNull { it.account.id == accountId }
// The row is gone the instant it is removed, while this screen is still
// sliding away. Keeping the last one it had stops that exit animating an
// empty screen.
var lastKnown by remember(accountId) { mutableStateOf(row) }
LaunchedEffect(row) { if (row != null) lastKnown = row }
val shown = row ?: lastKnown ?: return
val account = shown.account
val identity = account.identity()
// The confirmation sheet. The removal it starts outlives this screen, and is
// shown on the accounts list rather than here.
var confirming by remember { mutableStateOf(false) }
CollapsingScaffold(title = identity.title, onBack = onBack) {
AccountHero(identity)
Spacer(Modifier.height(24.dp))
val actions = buildList<@Composable (Position) -> Unit> {
add { position ->
GroupedRow(
title = stringResource(R.string.accounts_last_sync),
summary = syncState(shown),
position = position,
leading = { Icon(Icons.Rounded.History, contentDescription = null) },
)
}
if (!usable) return@buildList
push?.let { pushState ->
add { position ->
GroupedRow(
title = stringResource(R.string.accounts_push),
summary = accountPushSummary(pushState, shown.lists),
position = position,
leading = { Icon(Icons.Rounded.Bolt, contentDescription = null) },
)
}
}
if (shown.needsSignIn) {
add { position ->
GroupedRow(
title = stringResource(R.string.accounts_sign_in_again),
position = position,
leading = { Icon(Icons.AutoMirrored.Rounded.Login, contentDescription = null) },
onClick = { onSignInAgain(account) },
)
}
} else {
add { position ->
GroupedRow(
title = stringResource(R.string.accounts_sync_now),
position = position,
leading = { Icon(Icons.Rounded.CloudSync, contentDescription = null) },
onClick = { viewModel.syncNow(account) },
)
}
add { position ->
GroupedRow(
title = stringResource(R.string.accounts_lists),
summary = stringResource(R.string.accounts_lists_summary),
position = position,
leading = { Icon(Icons.AutoMirrored.Rounded.List, contentDescription = null) },
onClick = { viewModel.openLists(account.id) },
)
}
}
}
actions.forEachIndexed { index, action -> action(positionOf(index, actions.size)) }
if (!usable) {
Spacer(Modifier.height(24.dp))
ExternalStorageNotice(onOpenStorage)
}
// ⚠️ Above the removal, below the state — because it is the one thing on
// this screen the user did not already know. Conflicts are server-wins,
// and this group is the other half of it:
// without it a discarded edit is indistinguishable from lost work.
if (shown.notices.isNotEmpty()) {
Spacer(Modifier.height(24.dp))
SyncNotices(
notices = shown.notices,
onDismiss = { viewModel.dismissNotices(account) },
onRetry = viewModel::retry,
)
}
Spacer(Modifier.height(24.dp))
// Its own group, away from the things that are safe to press twice.
//
// ⚠️ Nothing here shows the removal in flight, deliberately. Confirming
// hands off to `onRemoved`, which puts this screen away in the same
// frame — so a pending state drawn here could never appear, and the
// dimming and spinner that were written for it were dead code claiming
// to prevent a second tap that cannot happen. The wait is visible where
// the user actually ends up: the row on `AccountsScreen`.
GroupedRow(
title = errorTitle(stringResource(R.string.accounts_remove)),
position = Position.Alone,
leading = {
Icon(
Icons.Rounded.DeleteOutline,
contentDescription = null,
tint = MaterialTheme.colorScheme.error,
)
},
onClick = { confirming = true },
)
Spacer(Modifier.height(24.dp))
}
val lists by viewModel.lists.collectAsStateWithLifecycle()
lists?.let { state ->
SyncedListsPicker(state = state, viewModel = viewModel)
}
if (confirming) {
RemoveAccountPicker(
identity = identity,
onDismiss = { confirming = false },
onRemove = { deleteLocalData ->
viewModel.remove(account, deleteLocalData)
confirming = false
onRemoved()
},
)
}
}
/**
* What the last syncs replaced or gave up on, one row each.
*
* Named, never counted. "3 edits were replaced" leaves the user to work out
* which three across every list they own, which is the same as not telling them
* — so each row carries the task's own title, the list it lives in, and the
* reason, and "Got it" is what clears them.
*/
@Composable
private fun SyncNotices(
notices: List<SyncNotice>,
onDismiss: () -> Unit,
onRetry: (SyncNotice) -> Unit,
) {
Text(
stringResource(R.string.sync_notices_title),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = GroupedListInset, vertical = 8.dp),
)
// The dismissal is a row of the same group: it is the last thing you do to
// this list, and a floating button beside it would read as belonging to the
// screen rather than to these notices.
val rows = notices.size + 1
notices.forEachIndexed { index, notice ->
GroupedRow(
title = notice.subject.ifBlank { stringResource(R.string.task_untitled) },
summary = stringResource(
R.string.accounts_summary,
notice.listName,
stringResource(notice.cause.message),
),
position = positionOf(index, rows),
leading = {
Icon(
if (notice.kind == SyncNotice.Kind.QUARANTINED) {
Icons.Rounded.SyncProblem
} else {
Icons.Rounded.HistoryToggleOff
},
contentDescription = null,
)
},
trailing = if (notice.kind == SyncNotice.Kind.QUARANTINED) {
{
IconButton(onClick = { onRetry(notice) }) {
Icon(
Icons.Rounded.Refresh,
contentDescription = stringResource(R.string.sync_notice_retry),
)
}
}
} else {
null
},
)
}
GroupedRow(
title = stringResource(R.string.sync_notices_dismiss),
position = positionOf(notices.size, rows),
leading = { Icon(Icons.Rounded.Check, contentDescription = null) },
onClick = onDismiss,
)
}
/**
* Why an edit is gone, in words rather than an enum name.
*
* A quarantine has no cause of its own — nothing chose it, a resource simply
* kept failing — so the null branch is the sentence for that, not a fallback.
*/
private val DiscardedEdit.Cause?.message: Int
get() = when (this) {
DiscardedEdit.Cause.SERVER_NEWER -> R.string.sync_notice_cause_server_newer
DiscardedEdit.Cause.DELETED_ON_SERVER -> R.string.sync_notice_cause_deleted_on_server
DiscardedEdit.Cause.DELETE_LOST -> R.string.sync_notice_cause_delete_lost
null -> R.string.sync_notice_cause_quarantined
}
/** The account's logo at full size, over the two things the title bar left out. */
@Composable
private fun AccountHero(identity: AccountIdentity) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = GroupedListInset),
horizontalAlignment = Alignment.CenterHorizontally,
) {
ProviderLogo(identity.provider, size = 72.dp)
Spacer(Modifier.height(12.dp))
identity.user?.let {
Text(it, style = MaterialTheme.typography.titleMedium)
}
Text(
identity.secondary ?: stringResource(R.string.accounts_generic_provider),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
/**
* Removing an account is two different things — the lists stay behind as
* device-only lists, or they go with it — so it is a browse-style choice, and
* those are full-screen. Each row says what it does and does it; leaving without
* choosing is back.
*/
@Composable
private fun RemoveAccountPicker(
identity: AccountIdentity,
onDismiss: () -> Unit,
onRemove: (deleteLocalData: Boolean) -> Unit,
) {
FullScreenPicker(
title = stringResource(R.string.accounts_remove),
onDismiss = onDismiss,
predictiveBack = true,
) {
Text(
stringResource(R.string.accounts_remove_body, identity.title),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = GroupedListInset),
)
Spacer(Modifier.height(16.dp))
GroupedRow(
title = stringResource(R.string.accounts_remove_keep),
summary = stringResource(R.string.accounts_remove_keep_body),
position = Position.Top,
leading = { Icon(Icons.Rounded.Inventory2, contentDescription = null) },
onClick = { onRemove(false) },
)
GroupedRow(
title = errorTitle(stringResource(R.string.accounts_remove_wipe)),
summary = AnnotatedString(stringResource(R.string.accounts_remove_wipe_body)),
position = Position.Bottom,
leading = {
Icon(
Icons.Rounded.DeleteOutline,
contentDescription = null,
tint = MaterialTheme.colorScheme.error,
)
},
onClick = { onRemove(true) },
)
}
}
/**
* Which of the account's collections sync, re-read from the server — so lists
* made there after setup can be added, and synced ones dropped.
*/
@Composable
private fun SyncedListsPicker(state: AccountsViewModel.ListsState, viewModel: AccountsViewModel) {
var confirmingDrop by remember { mutableStateOf(false) }
val ready = state as? AccountsViewModel.ListsState.Ready
FullScreenPicker(
title = stringResource(R.string.accounts_lists),
onDismiss = viewModel::closeLists,
predictiveBack = true,
actions = {
if (ready != null) {
Button(
onClick = {
if (ready.dropping) confirmingDrop = true else viewModel.saveLists(keepUnticked = true)
},
modifier = Modifier.padding(end = 12.dp),
) { Text(stringResource(R.string.save)) }
}
},
) {
when (state) {
AccountsViewModel.ListsState.Loading -> Row(
modifier = Modifier.padding(horizontal = GroupedListInset),
verticalAlignment = Alignment.CenterVertically,
) {
CircularProgressIndicator(Modifier.size(20.dp))
Spacer(Modifier.width(12.dp))
Text(
stringResource(R.string.accounts_lists_loading),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
AccountsViewModel.ListsState.NeedsSignIn -> PickerNote(stringResource(R.string.accounts_needs_sign_in))
is AccountsViewModel.ListsState.Failed -> PickerNote(
stringResource(state.cause?.message ?: R.string.add_account_error_server),
)
is AccountsViewModel.ListsState.Ready -> state.collections.forEachIndexed { index, collection ->
val checked = collection.url in state.selected
GroupedRow(
title = collection.displayName ?: collection.url.encodedPath,
summary = when {
collection.readOnly -> stringResource(R.string.add_account_lists_read_only)
collection.isShared -> stringResource(R.string.add_account_lists_shared)
else -> null
},
position = positionOf(index, state.collections.size),
selected = checked,
trailing = { if (checked) SelectedCheck() },
onClick = { viewModel.toggleList(collection.url) },
)
}
}
Spacer(Modifier.height(16.dp))
}
if (confirmingDrop) {
DropListsPicker(
onDismiss = { confirmingDrop = false },
onChoose = { keep ->
confirmingDrop = false
viewModel.saveLists(keepUnticked = keep)
},
)
}
}
@Composable
private fun PickerNote(text: String) {
Text(
text,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = GroupedListInset),
)
}
/** What happens to the tasks of lists that stop syncing — the same two ways removing an account offers. */
@Composable
private fun DropListsPicker(onDismiss: () -> Unit, onChoose: (keep: Boolean) -> Unit) {
FullScreenPicker(
title = stringResource(R.string.accounts_lists_drop_title),
onDismiss = onDismiss,
predictiveBack = true,
) {
PickerNote(stringResource(R.string.accounts_lists_drop_body))
Spacer(Modifier.height(16.dp))
GroupedRow(
title = stringResource(R.string.accounts_lists_drop_keep),
summary = stringResource(R.string.accounts_lists_drop_keep_body),
position = Position.Top,
leading = { Icon(Icons.Rounded.Inventory2, contentDescription = null) },
onClick = { onChoose(true) },
)
GroupedRow(
title = errorTitle(stringResource(R.string.accounts_lists_drop_wipe)),
summary = AnnotatedString(stringResource(R.string.accounts_lists_drop_wipe_body)),
position = Position.Bottom,
leading = {
Icon(
Icons.Rounded.DeleteOutline,
contentDescription = null,
tint = MaterialTheme.colorScheme.error,
)
},
onClick = { onChoose(false) },
)
}
}
/** A destructive row's title, in the error colour the row itself cannot take. */
@Composable
private fun errorTitle(text: String): AnnotatedString {
val error = MaterialTheme.colorScheme.error
return remember(text, error) {
buildAnnotatedString { withStyle(SpanStyle(color = error)) { append(text) } }
}
}
@@ -0,0 +1,225 @@
package de.jeanlucmakiola.agendula.ui.accounts
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.CloudSync
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.agendula.R
import de.jeanlucmakiola.agendula.data.tasks.room.AccountEntity
import de.jeanlucmakiola.caldav.CalDavProvider
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
/**
* What an account is called on screen, and by what mark.
*
* `display_name` is `user@host` — the right identity for the system account and
* for the sync trigger that keys off it, and far too long for a row title. Both
* halves are stored separately anyway, so the screen shows the one that
* identifies the account and demotes the other to the supporting line.
*/
internal data class AccountIdentity(
val provider: CalDavProvider?,
/** The name: a hosted service's brand, or the host of a server the user runs. */
val title: String,
/** The half [title] left out — the host under a brand, the software under a host. */
val secondary: String?,
/** Who, on that server. */
val user: String?,
)
internal fun AccountEntity.identity(): AccountIdentity {
val url = principalUrl?.toHttpUrlOrNull()
val provider = url?.let { CalDavProvider.forPrincipal(it) }
val host = url?.host?.removePrefix("www.")
val hosted = provider?.hosted == true
return AccountIdentity(
provider = provider,
// The fallback is the stored name: an account with no principal URL
// never got past discovery, so there is nothing better to call it.
title = if (hosted) provider.label else host ?: displayName,
secondary = if (hosted) host else provider?.label,
user = username?.takeIf { it.isNotBlank() },
)
}
/**
* The provider's logo: its mark, in white, on a disc of its own brand colour —
* the 40dp leading avatar Calendula gives a synced calendar, in colour, so a
* list of accounts is scannable by mark rather than by reading hostnames.
*
* White-on-brand rather than a brand-tinted glyph on a neutral chip, because it
* is the shape the marks are actually drawn in and it is the one treatment that
* needs no second colour for dark mode. Providers with no brand colour of their
* own take the app's [MaterialTheme] accent.
*
* There are three treatments, and which one a provider gets is decided by how
* that provider actually draws itself:
*
* 1. **A badge** — [CalDavProvider.badge] — fills the circle edge to edge in its
* own colours. Fastmail's icon *is* a ring, so a disc behind it would be a
* ring inside a circle, and knocking it back to white would throw away the
* logo's larger half.
* 2. **A mark** — [CalDavProvider.mark] — is tinted white on the brand's disc,
* which is how Nextcloud, Apple and Posteo draw these marks themselves.
* 3. **A lettermark** for everything else. Material's `AlternateEmail` was doing
* duty for six providers at once, so a list meant to be read by mark showed
* one glyph six times; the brand's own initial tells them apart and claims
* nothing. The case comes from [CalDavProvider.label], which is why iCloud
* and mailbox.org keep their lowercase letterforms.
*
* A real logo beats a letter; an *approximated* logo beats neither, which is why
* the rest wait for their own art rather than for a good guess at it — and why
* every mark here is generated from the vendor's own file, not traced by eye.
*/
@Composable
internal fun ProviderLogo(provider: CalDavProvider?, size: Dp = 40.dp) {
val badge = provider?.badge
if (badge != null) {
// ⚠️ On white, not on nothing. The badge is a ring with a transparent
// middle, drawn for a white page — dropped straight onto the row it lets
// the surface through, and in dark mode the navy envelope inside it goes
// very nearly invisible. White is the background the art is drawn for, so
// it is the background it gets, in both themes.
Box(
modifier = Modifier
.size(size)
.clip(CircleShape)
.background(Color.White),
contentAlignment = Alignment.Center,
) {
Image(
painter = painterResource(badge),
contentDescription = null,
modifier = Modifier.size(size),
)
}
return
}
val accent = provider?.accent
Box(
modifier = Modifier
.size(size)
.clip(CircleShape)
.background(accent ?: MaterialTheme.colorScheme.primary),
contentAlignment = Alignment.Center,
) {
val tint = if (accent != null) Color.White else MaterialTheme.colorScheme.onPrimary
val mark = provider?.mark
when {
mark != null -> Icon(
painter = painterResource(mark.res),
contentDescription = null,
tint = tint,
modifier = Modifier.size(size * mark.fraction),
)
// A server we know nothing about has no initial to wear.
provider == null -> Icon(
imageVector = Icons.Rounded.CloudSync,
contentDescription = null,
tint = tint,
modifier = Modifier.size(size * GLYPH_FRACTION),
)
else -> Text(
text = provider.letter,
color = tint,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
fontSize = with(LocalDensity.current) { (size * LETTER_FRACTION).toSp() },
// The name is on the row beside it. A screen reader announcing a
// bare "F" before "Fastmail" is noise, not information.
modifier = Modifier.clearAndSetSemantics { },
)
}
}
}
/** A provider whose official icon is a finished badge, colours and all. */
private val CalDavProvider.badge: Int?
get() = when (this) {
CalDavProvider.FASTMAIL -> R.drawable.ic_provider_fastmail
CalDavProvider.MAILBOX_ORG -> R.drawable.ic_provider_mailbox
else -> null
}
/**
* The letter a service is known by.
*
* Its own initial, except where the service's actual mark *is* a different
* letter: Yandex's is a Cyrillic Я, which is something we can set rather than
* art we would have to trace — the only vector they publish is a 64px raster.
*/
private val CalDavProvider.letter: String
get() = when (this) {
CalDavProvider.YANDEX -> "Я"
else -> label.take(1)
}
/**
* A monochrome mark and how much of the disc it is given.
*
* The fraction is not one number because the marks are not one shape: a wide
* mark squared off into the same box reads smaller than a compact one, so it is
* given more room to land on the same optical weight.
*/
private data class Mark(val res: Int, val fraction: Float)
private val CalDavProvider.mark: Mark?
get() = when (this) {
CalDavProvider.NEXTCLOUD -> Mark(R.drawable.ic_provider_nextcloud, WIDE_MARK_FRACTION)
CalDavProvider.ICLOUD -> Mark(R.drawable.ic_provider_icloud, WIDE_MARK_FRACTION)
CalDavProvider.POSTEO -> Mark(R.drawable.ic_provider_posteo, GLYPH_FRACTION)
else -> null
}
/**
* The provider's own brand colour, where it publishes one recognisable enough to
* be worth carrying. Null means "we would be inventing it" — Baïkal, DAViCal and
* SOGo have no colour anyone would recognise, so they take the theme's accent
* instead of a made-up one.
*
* Each is dark enough to carry a white mark, which is the whole treatment: no
* dark-mode variant is needed because neither colour in the pair moves.
*/
private val CalDavProvider.accent: Color?
get() = when (this) {
CalDavProvider.NEXTCLOUD -> Color(0xFF0082C9)
CalDavProvider.ICLOUD -> Color(0xFF007AFF)
CalDavProvider.GOOGLE -> Color(0xFF1A73E8)
CalDavProvider.FASTMAIL -> Color(0xFF2B6CB0)
CalDavProvider.MAILBOX_ORG -> Color(0xFF0069B4)
// Their own, off their app icon — not the darker green that was
// guessed at before the art arrived.
CalDavProvider.POSTEO -> Color(0xFFA9D158)
CalDavProvider.ZOHO -> Color(0xFFE42527)
CalDavProvider.YANDEX -> Color(0xFFFF2500)
CalDavProvider.BAIKAL, CalDavProvider.DAVICAL, CalDavProvider.SOGO -> null
}
/** The mark sits on the disc the way a launcher icon does — a little over half. */
private const val GLYPH_FRACTION = 0.55f
/** A letter reads smaller than a glyph of the same box, so it is given less. */
private const val LETTER_FRACTION = 0.44f
/** A wide mark squared off into the same box has to be given more to match. */
private const val WIDE_MARK_FRACTION = 0.72f
@@ -0,0 +1,318 @@
package de.jeanlucmakiola.agendula.ui.accounts
import android.text.format.DateUtils
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.Login
import androidx.compose.material.icons.rounded.Add
import androidx.compose.material.icons.rounded.CloudSync
import androidx.compose.material.icons.rounded.Storage
import androidx.compose.material.icons.rounded.Sync
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.agendula.R
import de.jeanlucmakiola.agendula.data.prefs.SYNC_INTERVAL_PRESETS
import de.jeanlucmakiola.agendula.data.sync.SyncFailure
import de.jeanlucmakiola.agendula.data.tasks.room.AccountEntity
import de.jeanlucmakiola.agendula.ui.common.OnResume
import de.jeanlucmakiola.agendula.ui.settings.SettingsHint
import de.jeanlucmakiola.floret.components.CollapsingScaffold
import de.jeanlucmakiola.floret.components.GroupedListInset
import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.OptionPicker
import de.jeanlucmakiola.floret.components.Position
import de.jeanlucmakiola.floret.components.positionOf
/** The CalDAV accounts this device syncs with. */
@Composable
internal fun AccountsScreen(
onAddAccount: () -> Unit,
onSignInAgain: (AccountEntity) -> Unit,
onOpenAccount: (Long) -> Unit,
onOpenStorage: () -> Unit,
onBack: () -> Unit,
viewModel: AccountsViewModel,
) {
val accounts by viewModel.accounts.collectAsStateWithLifecycle()
val usable by viewModel.accountsUsable.collectAsStateWithLifecycle()
val syncInterval by viewModel.syncIntervalMinutes.collectAsStateWithLifecycle()
val push by viewModel.push.collectAsStateWithLifecycle()
var showInterval by remember { mutableStateOf(false) }
var showPush by remember { mutableStateOf(false) }
OnResume(viewModel::refreshPush)
CollapsingScaffold(
title = stringResource(R.string.settings_section_accounts),
onBack = onBack,
) {
val loaded = accounts ?: return@CollapsingScaffold
if (loaded.isEmpty()) {
Icon(
Icons.Rounded.CloudSync,
contentDescription = null,
modifier = Modifier.padding(horizontal = GroupedListInset),
)
Spacer(Modifier.height(12.dp))
Text(
stringResource(R.string.accounts_empty_title),
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(horizontal = GroupedListInset),
)
Spacer(Modifier.height(4.dp))
Text(
stringResource(R.string.accounts_empty_body),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = GroupedListInset),
)
Spacer(Modifier.height(24.dp))
} else {
loaded.forEachIndexed { index, row ->
val account = row.account
val identity = account.identity()
GroupedRow(
title = identity.title,
// One line, so it says the most useful thing it can. A row
// on its way out has one piece of news; an account holding
// unread sync reports has another, and either outranks
// "synced 5 minutes ago" — but neither outranks a sign-in
// that has stopped, which `syncState` already puts first.
summary = when {
row.removing -> stringResource(R.string.accounts_removing)
row.notices.isNotEmpty() && !row.needsSignIn -> accountSummary(
identity.user,
pluralStringResource(
R.plurals.accounts_notices,
row.notices.size,
row.notices.size,
),
)
else -> accountSummary(identity.user, syncState(row))
},
position = positionOf(index, loaded.size),
// A row whose account is on its way out is not a row you can
// act on, so it stops looking like one: dimmed, its action
// replaced by the progress, and nothing to tap.
dimmed = row.removing,
leading = { ProviderLogo(identity.provider) },
trailing = {
when {
row.removing -> CircularProgressIndicator(Modifier.size(20.dp))
!usable -> Unit
row.needsSignIn -> IconButton(onClick = { onSignInAgain(account) }) {
Icon(
Icons.AutoMirrored.Rounded.Login,
contentDescription = stringResource(R.string.accounts_sign_in_again),
)
}
else -> IconButton(onClick = { viewModel.syncNow(account) }) {
Icon(
Icons.Rounded.Sync,
contentDescription = stringResource(R.string.accounts_sync_now),
)
}
}
},
onClick = { onOpenAccount(account.id) }.takeUnless { row.removing },
)
}
Spacer(Modifier.height(24.dp))
if (usable) {
val pushState = push
GroupedRow(
title = stringResource(R.string.accounts_sync_interval),
summary = syncIntervalLabel(syncInterval),
position = if (pushState == null) Position.Alone else Position.Top,
onClick = { showInterval = true },
)
if (pushState != null) {
GroupedRow(
title = stringResource(R.string.accounts_push),
summary = pushSummary(pushState),
position = Position.Bottom,
onClick = { showPush = true },
)
}
Spacer(Modifier.height(24.dp))
}
}
if (usable) {
GroupedRow(
title = stringResource(R.string.accounts_add),
position = Position.Alone,
leading = { Icon(Icons.Rounded.Add, contentDescription = null) },
onClick = onAddAccount,
)
} else {
ExternalStorageNotice(onOpenStorage)
}
}
if (showInterval) {
OptionPicker(
title = stringResource(R.string.accounts_sync_interval),
options = SYNC_INTERVAL_PRESETS,
selected = syncInterval,
label = { syncIntervalLabel(it) },
header = {
SettingsHint(stringResource(R.string.accounts_sync_interval_hint))
Spacer(Modifier.height(8.dp))
},
onSelect = viewModel::setSyncInterval,
onDismiss = { showInterval = false },
)
}
val pushState = push
if (showPush && pushState != null) {
PushPicker(
push = pushState,
onSelect = viewModel::choosePushDistributor,
onDismiss = { showPush = false },
)
}
}
@Composable
private fun PushPicker(
push: AccountsViewModel.PushUi,
onSelect: (String?) -> Unit,
onDismiss: () -> Unit,
) {
OptionPicker(
title = stringResource(R.string.accounts_push),
options = listOf(PUSH_OFF) + push.installed.map { it.packageName },
// Nothing is ticked while push is on but no distributor is chosen yet.
selected = if (push.enabled) push.current?.packageName else PUSH_OFF,
label = { option ->
if (option == PUSH_OFF) {
stringResource(R.string.accounts_push_off)
} else {
push.installed.first { it.packageName == option }.label
}
},
header = {
SettingsHint(stringResource(R.string.accounts_push_hint))
if (push.installed.isEmpty()) SettingsHint(stringResource(R.string.accounts_push_none_installed))
Spacer(Modifier.height(8.dp))
},
onSelect = { option -> onSelect(option.takeUnless { it == PUSH_OFF }) },
onDismiss = onDismiss,
)
}
/** The picker's "Off" row, which no package can be named. */
private const val PUSH_OFF = ""
@Composable
internal fun pushSummary(push: AccountsViewModel.PushUi): String {
val current = push.current
val lists = push.lists
return when {
!push.enabled -> stringResource(R.string.accounts_push_off)
push.needsChoice -> stringResource(R.string.accounts_push_choose)
current == null -> stringResource(R.string.accounts_push_no_distributor)
lists.capable == 0 -> stringResource(R.string.accounts_push_unsupported)
lists.subscribed == 0 -> stringResource(R.string.accounts_push_pending)
lists.subscribed < lists.synced -> pluralStringResource(
R.plurals.accounts_push_partial,
lists.synced,
lists.subscribed,
lists.synced,
current.label,
)
else -> stringResource(R.string.accounts_push_via, current.label)
}
}
/** The same, for one account's lists. */
@Composable
internal fun accountPushSummary(push: AccountsViewModel.PushUi, lists: AccountsViewModel.ListPush): String =
when {
!push.enabled -> stringResource(R.string.accounts_push_off)
push.needsChoice -> stringResource(R.string.accounts_push_choose)
push.current == null -> stringResource(R.string.accounts_push_no_distributor)
lists.capable == 0 -> stringResource(R.string.accounts_push_account_unsupported)
lists.subscribed == 0 -> stringResource(R.string.accounts_push_pending)
lists.subscribed < lists.synced -> pluralStringResource(
R.plurals.accounts_push_account_partial,
lists.synced,
lists.subscribed,
lists.synced,
)
else -> stringResource(R.string.accounts_push_account_on)
}
@Composable
private fun syncIntervalLabel(minutes: Int): String = when {
minutes <= 0 -> stringResource(R.string.accounts_sync_interval_manual)
minutes % 60 == 0 -> pluralStringResource(R.plurals.accounts_sync_every_hours, minutes / 60, minutes / 60)
else -> pluralStringResource(R.plurals.accounts_sync_every_minutes, minutes, minutes)
}
/** In External mode accounts stay, but sync into a store nothing is showing. */
@Composable
internal fun ExternalStorageNotice(onOpenStorage: () -> Unit) {
GroupedRow(
title = stringResource(R.string.accounts_external_storage_title),
summary = stringResource(R.string.accounts_external_storage),
position = Position.Alone,
leading = { Icon(Icons.Rounded.Storage, contentDescription = null) },
onClick = onOpenStorage,
)
}
/**
* Never the raw values: `lastSyncError` is an exception string and `lastSyncAt`
* renders as an ISO-8601 UTC instant, and both bypass `strings.xml` entirely.
*/
@Composable
internal fun syncState(row: AccountsViewModel.AccountRow): String = when {
// Distinct from a failed sync on purpose: this one has stopped retrying, and
// only the user can restart it.
row.needsSignIn -> stringResource(R.string.accounts_needs_sign_in)
row.account.lastSyncError != null -> failureText(SyncFailure.of(row.account.lastSyncError))
row.account.lastSyncAt != null -> DateUtils.getRelativeTimeSpanString(
row.account.lastSyncAt.toEpochMilliseconds(),
System.currentTimeMillis(),
DateUtils.MINUTE_IN_MILLIS,
).toString()
else -> stringResource(R.string.accounts_never_synced)
}
/** Why the last sync failed, by class — never the engine's own words. */
@Composable
private fun failureText(failure: SyncFailure): String = when (failure.kind) {
SyncFailure.Kind.SIGN_IN -> stringResource(R.string.accounts_failed_sign_in)
SyncFailure.Kind.UNREACHABLE -> stringResource(R.string.accounts_failed_unreachable)
SyncFailure.Kind.CERTIFICATE -> stringResource(R.string.accounts_failed_certificate)
SyncFailure.Kind.SERVER -> failure.httpCode
?.let { stringResource(R.string.accounts_failed_server_code, it) }
?: stringResource(R.string.accounts_failed_server)
SyncFailure.Kind.MISCONFIGURED -> stringResource(R.string.accounts_failed_misconfigured)
SyncFailure.Kind.OTHER -> stringResource(R.string.accounts_sync_failed)
}
/** Who, then how it last went — the two things the title does not already say. */
@Composable
private fun accountSummary(user: String?, state: String): String =
if (user == null) state else stringResource(R.string.accounts_summary, user, state)
@@ -0,0 +1,356 @@
package de.jeanlucmakiola.agendula.ui.accounts
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import de.jeanlucmakiola.agendula.data.di.IoDispatcher
import de.jeanlucmakiola.agendula.data.prefs.DEFAULT_SYNC_INTERVAL_MINUTES
import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.agendula.data.sync.AccountRepository
import de.jeanlucmakiola.agendula.data.sync.AccountStateStore
import de.jeanlucmakiola.agendula.data.sync.SyncAvailability
import de.jeanlucmakiola.agendula.data.sync.SyncNotice
import de.jeanlucmakiola.agendula.data.sync.SyncNoticeStore
import de.jeanlucmakiola.agendula.data.sync.SyncTrigger
import de.jeanlucmakiola.agendula.data.sync.push.PushDistributors
import de.jeanlucmakiola.agendula.data.sync.push.PushRegistrar
import de.jeanlucmakiola.agendula.data.sync.push.PushStore
import de.jeanlucmakiola.agendula.data.tasks.room.AccountEntity
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import de.jeanlucmakiola.caldav.CalDavDiscovery
import de.jeanlucmakiola.caldav.TaskCollection
import okhttp3.HttpUrl
import javax.inject.Inject
@HiltViewModel
class AccountsViewModel @Inject constructor(
private val repository: AccountRepository,
private val syncTrigger: SyncTrigger,
private val accountState: AccountStateStore,
private val notices: SyncNoticeStore,
private val settings: SettingsPrefs,
private val distributors: PushDistributors,
private val pushRegistrar: PushRegistrar,
pushStore: PushStore,
availability: SyncAvailability,
@IoDispatcher io: CoroutineDispatcher,
) : ViewModel() {
/** Minutes between background syncs; 0 = manual only. */
val syncIntervalMinutes: StateFlow<Int> = settings.settings.map { it.syncIntervalMinutes }.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(STOP_TIMEOUT_MILLIS),
initialValue = DEFAULT_SYNC_INTERVAL_MINUTES,
)
/** Store the new interval and put every account on it. */
fun setSyncInterval(minutes: Int) {
viewModelScope.launch {
settings.setSyncIntervalMinutes(minutes)
repository.rescheduleAll(intervalChanged = true)
}
}
/**
* An account's synced lists, how many its server offers push for, and how
* many actually hold a subscription.
*/
data class ListPush(val synced: Int, val capable: Int, val subscribed: Int) {
companion object {
val NONE = ListPush(synced = 0, capable = 0, subscribed = 0)
}
}
private val listPush: Flow<Map<Long, ListPush>> =
combine(repository.observeSyncedLists(), pushStore.observe()) { lists, pushes ->
lists.groupBy { it.accountId!! }.mapValues { (_, synced) ->
ListPush(
synced = synced.size,
capable = synced.count { pushes[it.id]?.support != null },
subscribed = synced.count { pushes[it.id]?.subscription != null },
)
}
}.distinctUntilChanged()
/** Push as the Accounts screen shows it. */
data class PushUi(
val enabled: Boolean,
val installed: List<PushDistributors.Distributor>,
/** The distributor in use; null when push is off or none is chosen. */
val current: PushDistributors.Distributor?,
val lists: ListPush,
) {
/** On, distributors installed, and none chosen or set as default. */
val needsChoice: Boolean get() = enabled && current == null && installed.isNotEmpty()
}
private data class DistributorState(
val enabled: Boolean,
val installed: List<PushDistributors.Distributor>,
val current: PushDistributors.Distributor?,
)
/** Distributors are installed and removed outside the app; bumped on resume. */
private val pushRefresh = MutableStateFlow(0)
/** Package queries only on a setting change or a refresh, and off the main thread. */
private val distributorState: Flow<DistributorState> = combine(
settings.settings.map { it.pushEnabled }.distinctUntilChanged(),
pushRefresh,
) { enabled, _ ->
val installed = distributors.installed()
val current = distributors.saved().takeIf { enabled }?.let { chosen ->
installed.firstOrNull { it.packageName == chosen }
?: PushDistributors.Distributor(chosen, distributors.labelOf(chosen))
}
DistributorState(enabled, installed, current)
}.flowOn(io)
val push: StateFlow<PushUi?> = combine(distributorState, listPush) { state, perAccount ->
PushUi(
enabled = state.enabled,
installed = state.installed,
current = state.current,
lists = ListPush(
synced = perAccount.values.sumOf { it.synced },
capable = perAccount.values.sumOf { it.capable },
subscribed = perAccount.values.sumOf { it.subscribed },
),
)
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(STOP_TIMEOUT_MILLIS),
initialValue = null,
)
/**
* Re-reads the installed distributors. With push on and none chosen yet, a
* newly installed one is adopted and registered first, so the row never
* names a distributor nothing has registered with.
*/
fun refreshPush() {
viewModelScope.launch {
if (settings.settings.first().pushEnabled && distributors.saved() == null) {
runCatching { pushRegistrar.updateAll() }
}
pushRefresh.update { it + 1 }
}
}
/** @param packageName the distributor to use, or null to turn push off. */
fun choosePushDistributor(packageName: String?) {
viewModelScope.launch {
if (packageName == null) distributors.disable() else distributors.select(packageName)
runCatching { pushRegistrar.updateAll() }
pushRefresh.update { it + 1 }
}
}
/** False in External storage mode, where accounts are kept but neither added nor synced. */
val accountsUsable: StateFlow<Boolean> = availability.observe().stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(STOP_TIMEOUT_MILLIS),
initialValue = true,
)
/** An account row, plus the two things the row cannot read from the entity. */
data class AccountRow(
val account: AccountEntity,
val needsSignIn: Boolean,
/**
* A removal is under way for this account.
*
* The row survives it: `remove()` revokes the app password over the
* network before it touches any store, so several seconds pass — on a
* server that stalls, longer — during which the row sat there looking
* completely untouched.
*/
val removing: Boolean = false,
/**
* What the last syncs destroyed or gave up on, newest first.
*
* ⚠️ On the row rather than in a flow of its own, because it has to
* survive the account it belongs to going away: an account is removed
* by id, and a notice keyed to an id nothing lists any more is a line
* of text about nothing.
*/
val notices: List<SyncNotice> = emptyList(),
val lists: ListPush = ListPush.NONE,
)
/**
* Accounts whose removal has started and not finished.
*
* ⚠️ Also the double-tap guard. Nothing else stops a second Remove from
* starting a second revocation of a credential the first one is already
* handing back — harmless since `c2166b9`, because the sequence is
* idempotent either way, but it spends a second network round trip and
* leaves the user watching two things happen to one account.
*/
private val _removing = MutableStateFlow<Set<Long>>(emptySet())
/**
* `null` until the first load, so the empty state does not flash.
*
* ⚠️ Observed, not fetched. The sync that a tap on this screen starts
* finishes on a background thread some seconds later, and a snapshot taken
* when the screen opened cannot show it — the row went on saying "never
* synced" until the user left and came back. This also carries a *background*
* sync, and a 401 that stops an account, onto a screen already open.
*/
val accounts: StateFlow<List<AccountRow>?> =
combine(
repository.observeAll(),
accountState.observeNeedingSignIn(),
_removing,
notices.observeAll(),
listPush,
) { accounts, stopped, removing, allNotices, perAccount ->
val byAccount = allNotices.groupBy { it.accountId }
accounts.map {
AccountRow(
account = it,
needsSignIn = it.id in stopped,
removing = it.id in removing,
notices = byAccount[it.id].orEmpty(),
lists = perAccount[it.id] ?: ListPush.NONE,
)
}
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(STOP_TIMEOUT_MILLIS),
initialValue = null,
)
/**
* The app's own sync trigger.
*
* Not `ContentResolver.requestSync`: that is gated behind
* `hasAuthorityAccess()` at our targetSdk and returns silently when it
* refuses, which would leave the user pressing a button that does nothing.
*/
fun syncNow(account: AccountEntity) {
// Expedited: the user is looking at the button. Every other trigger is
// ordinary work, so the exhaustible per-app quota is spent here or not
// at all.
syncTrigger.enqueue(account.displayName, expedited = true)
}
/** The user has read what the last sync changed. */
fun dismissNotices(account: AccountEntity) {
viewModelScope.launch { notices.dismiss(account.id) }
}
/** Clears a quarantined task's failure count and syncs, so it is tried again. */
fun retry(notice: SyncNotice) {
viewModelScope.launch { repository.retryQuarantined(notice.accountId, notice.key) }
}
fun remove(account: AccountEntity, deleteLocalData: Boolean) {
if (account.id in _removing.value) return
_removing.update { it + account.id }
// No refresh: the row disappears because the query behind `accounts`
// re-emits.
viewModelScope.launch {
try {
repository.remove(account.id, account.displayName, deleteLocalData)
} finally {
// ⚠️ In a `finally`, and not only on the happy path. `remove()`
// finishes its destructive tail uncancellable, so the id would
// otherwise be stranded in the set by the one case that reaches
// here without completing normally — leaving a row that is gone
// from Room but pending for ever if it ever came back.
_removing.update { it - account.id }
}
}
}
/** The account's collections, re-read from the server for the lists picker. */
sealed interface ListsState {
data object Loading : ListsState
data class Ready(
val accountId: Long,
val collections: List<TaskCollection>,
val attached: Set<String>,
val selected: Set<HttpUrl>,
) : ListsState {
/** Synced lists this selection would stop syncing — the ones worth asking about. */
val dropping: Boolean
get() = collections.any { it.url.toString() in attached && it.url !in selected }
}
data object NeedsSignIn : ListsState
data class Failed(val cause: CalDavDiscovery.Outcome.Cause?) : ListsState
}
private val _lists = MutableStateFlow<ListsState?>(null)
/** Null while the picker is closed. */
val lists: StateFlow<ListsState?> = _lists
fun openLists(accountId: Long) {
_lists.value = ListsState.Loading
viewModelScope.launch {
val loaded = when (val found = repository.collections(accountId)) {
is AccountRepository.Collections.Found -> ListsState.Ready(
accountId = accountId,
collections = found.collections,
attached = found.attached,
selected = found.collections
.filter { it.url.toString() in found.attached }
.map { it.url }
.toSet(),
)
AccountRepository.Collections.NeedsSignIn -> ListsState.NeedsSignIn
is AccountRepository.Collections.Failed -> ListsState.Failed(found.cause)
}
// Closed while it loaded: stay closed.
if (_lists.value == ListsState.Loading) _lists.value = loaded
}
}
fun toggleList(url: HttpUrl) {
_lists.update { state ->
if (state !is ListsState.Ready) return@update state
val selected = if (url in state.selected) state.selected - url else state.selected + url
state.copy(selected = selected)
}
}
fun closeLists() {
_lists.value = null
}
/** @param keepUnticked what happens to the tasks of lists that stop syncing. */
fun saveLists(keepUnticked: Boolean) {
val state = _lists.value as? ListsState.Ready ?: return
_lists.value = null
viewModelScope.launch {
repository.setSyncedCollections(
accountId = state.accountId,
offered = state.collections,
selected = state.selected,
keepUnticked = keepUnticked,
)
}
}
private companion object {
/** Survives a configuration change without re-subscribing. */
const val STOP_TIMEOUT_MILLIS = 5_000L
}
}
@@ -0,0 +1,59 @@
package de.jeanlucmakiola.agendula.ui.accounts.add
import de.jeanlucmakiola.caldav.ServerQuirk
/**
* Something the add-account flow has to tell the user, in a form the UI can
* translate.
*
* ⚠️ A type, not a `String`, and not a `@StringRes Int` either. The flow used to
* build its sentences in the ViewModel, so a dozen of them shipped in English to
* every locale — the same defect `CalDavDiscovery.Outcome.Cause` was introduced
* to fix, which it only ever fixed for the address step. Nothing lints for it:
* `HardcodedText` reads XML layout attributes, and this app has none. Making the
* state fields carry this makes a literal a compile error, which is the only
* guard available.
*
* A resource id would work too, but two of these need a format argument and an
* `Int` accepts any other `Int` — this way the argument travels with the message
* that needs it, and a test can assert on meaning rather than on prose.
*/
sealed interface AddAccountMessage {
/** Something is happening, and the step says which. */
sealed interface Progress : AddAccountMessage {
data object Discovering : Progress
data object SigningIn : Progress
data object ReadingLists : Progress
data object Saving : Progress
}
/** Something went wrong, and the step says what. */
sealed interface Problem : AddAccountMessage
data object GoogleUnsupported : Problem
data object AlreadyExists : Problem
data object ExternalStorage : Problem
data object NoUsableLists : Problem
data object NotSaved : Problem
data object KeystoreRefused : Problem
data object CredentialsRejected : Problem
data object BrowserApprovalExpired : Problem
data object BrowserRateLimited : Problem
data object BrowserMaintenance : Problem
data object BrowserFailed : Problem
/** No browser could be opened at all — AOSP, GrapheneOS, a locked-down profile. */
data object BrowserUnavailable : Problem
/**
* A home set on a host the credential is not scoped to.
*
* The host travels with the message rather than being baked into a sentence,
* so the translation decides where it goes.
*/
data class OutsideCredentialScope(val host: String) : Problem
/** A provider whose real requirement is not "wrong password". */
data class Quirk(val quirk: ServerQuirk) : Problem
}
@@ -0,0 +1,172 @@
package de.jeanlucmakiola.agendula.ui.accounts.add
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringArrayResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.agendula.R
import de.jeanlucmakiola.caldav.ServerQuirk
import de.jeanlucmakiola.floret.components.GroupedListInset
import de.jeanlucmakiola.floret.components.GroupedSurface
import de.jeanlucmakiola.floret.components.InstructionSteps
import de.jeanlucmakiola.floret.components.InlineTextField
import de.jeanlucmakiola.floret.components.Position
/**
* The family's text input: a tonal grouped surface with a borderless field in
* it, never Material's outlined box.
*
* The label sits above the value rather than floating into a notch, because a
* `GroupedSurface` has no outline for a notch to interrupt.
*/
@Composable
internal fun FieldRow(
label: String,
value: String,
onValueChange: (String) -> Unit,
modifier: Modifier = Modifier,
position: Position = Position.Alone,
placeholder: String = "",
error: String? = null,
hint: String? = null,
keyboardType: KeyboardType = KeyboardType.Text,
onImeAction: (() -> Unit)? = null,
) {
// ⚠️ KeyboardType.Password only tells the IME to drop suggestions; it does
// not mask anything. Without the transformation the app password renders in
// the clear on screen.
val masked = keyboardType == KeyboardType.Password
Column(modifier) {
GroupedSurface(position = position) {
Column(Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp)) {
Text(
label,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
InlineTextField(
value = value,
onValueChange = onValueChange,
placeholder = placeholder,
keyboardType = keyboardType,
// A server address and a password are both case-sensitive,
// and sentence-casing either is a support ticket.
capitalization = KeyboardCapitalization.None,
imeAction = ImeAction.Go,
onImeAction = onImeAction,
visualTransformation = if (masked) {
PasswordVisualTransformation()
} else {
VisualTransformation.None
},
)
}
}
// ⚠️ Outside the surface, not inside it. An error rendered within the
// field's own card reads as part of the value the user typed.
(error ?: hint)?.let {
Text(
it,
style = MaterialTheme.typography.bodySmall,
color = if (error != null) {
MaterialTheme.colorScheme.error
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 6.dp),
)
}
}
}
/** The shell's primary action shape, so every step ends identically. */
@Composable
internal fun PrimaryAction(label: String, enabled: Boolean, onClick: () -> Unit) {
Button(
onClick = onClick,
enabled = enabled,
modifier = Modifier.fillMaxWidth().height(56.dp),
) {
Text(label, style = MaterialTheme.typography.titleMedium)
}
}
/**
* What the chosen service requires, before it is required.
*
* ⚠️ Steps, not a paragraph. Fastmail and iCloud both reject the account
* password with a plain 401, and the fix is a four-action errand in someone
* else's web app — the exact shape of instruction that gets skimmed and missed
* when it is written as prose. Google is the odd one out and stays a sentence:
* there is no procedure, because there is nothing the user can do.
*
* ⚠️ Collapsed. By the time this draws, the errand has had a whole screen of its
* own — [SetupStep] — so what is left to do here is let someone re-read it
* without going back, not print it a second time under the field they are
* trying to fill in.
*/
@Composable
internal fun QuirkGuidance(quirk: ServerQuirk) {
when (quirk) {
ServerQuirk.FASTMAIL_APP_PASSWORD -> InstructionSteps(
title = stringResource(R.string.add_account_setup_fastmail_title),
steps = stringArrayResource(R.array.add_account_setup_fastmail_steps).asList(),
footnote = stringResource(R.string.add_account_setup_fastmail_footnote),
collapsible = true,
)
ServerQuirk.ICLOUD_APP_SPECIFIC_PASSWORD -> InstructionSteps(
title = stringResource(R.string.add_account_setup_icloud_title),
steps = stringArrayResource(R.array.add_account_setup_icloud_steps).asList(),
footnote = stringResource(R.string.add_account_setup_icloud_footnote),
collapsible = true,
)
ServerQuirk.GOOGLE_UNSUPPORTED ->
QuirkNote(stringResource(R.string.add_account_quirk_google))
// A pre-flight warning for the engine, never something to read.
ServerQuirk.NEXTCLOUD_BRUTE_FORCE_PROTECTED -> Unit
}
}
/**
* A single fact the user has to read — one that is not a procedure, so it is a
* card rather than an [InstructionSteps] list of one.
*
* ⚠️ Inset to `GroupedListInset`, like a grouped row. Without it the note ran to
* the screen edge while every row above it stopped 16dp short, so a caller that
* is already inside a padded column must not add its own — see [BrowserStep].
*/
@Composable
internal fun QuirkNote(text: String) {
GroupedSurface(
position = Position.Alone,
modifier = Modifier.padding(horizontal = GroupedListInset),
// Not tertiaryContainer: under dynamic colour that is the low-chroma
// role, and against a light wallpaper-derived surface the card's own
// edge disappears even though its text pairing is fine.
color = MaterialTheme.colorScheme.secondaryContainer,
gapBelow = false,
) {
Text(
text,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSecondaryContainer,
modifier = Modifier.padding(GroupedListInset),
)
}
}
@@ -0,0 +1,460 @@
package de.jeanlucmakiola.agendula.ui.accounts.add
import android.content.Intent
import androidx.activity.compose.BackHandler
import androidx.browser.customtabs.CustomTabsIntent
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
import androidx.compose.material.icons.rounded.Checklist
import androidx.compose.material.icons.rounded.CloudOff
import androidx.compose.material.icons.rounded.CloudSync
import androidx.compose.material.icons.rounded.Lock
import androidx.compose.material.icons.rounded.OpenInBrowser
import androidx.compose.material.icons.rounded.Warning
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.core.net.toUri
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.agendula.R
import de.jeanlucmakiola.agendula.ui.accounts.ProviderLogo
import de.jeanlucmakiola.floret.components.OnboardingProgress
import de.jeanlucmakiola.floret.components.OnboardingScaffold
import de.jeanlucmakiola.floret.components.OnboardingSpace
/**
* Adding a CalDAV account: one flow, one back-stack entry.
*
* A stepper rather than several destinations, because the steps are not
* independently reachable — you cannot pick lists before signing in, and going
* "back" from the browser step means abandoning a server-side flow rather than
* popping a screen. The steps that *have* written nothing do step back, in the
* flow rather than out of it; [AddAccountViewModel.onBackWithin] decides which,
* and the shell falls out only when it says there is nowhere left to go. The
* back arrow and the system back gesture are the same action and run the same
* code — a stepper whose gesture skips the steps is a stepper with one way in
* and no way back.
*
* [stepOffset] and [totalSteps] let a longer flow host this one inline — first
* run offers the account before it has finished onboarding — so the wizard's
* four steps report their place in the *outer* progress rather than restarting
* the count at one. Left at their defaults it stands alone.
*/
@Composable
internal fun AddAccountScreen(
onDone: () -> Unit,
onBack: () -> Unit,
stepOffset: Int = 0,
// Null when the wizard stands alone: it then counts its own steps, which
// vary by provider. A host that draws one bar for a longer flow passes the
// whole denominator, having already asked the wizard how long it is.
totalSteps: Int? = null,
viewModel: AddAccountViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsStateWithLifecycle()
val context = LocalContext.current
LaunchedEffect(state.step) {
if (state.step !is AddAccountStep.Done) return@LaunchedEffect
onDone()
// The ViewModel is scoped to the Settings back-stack entry and survives
// this section being hidden, so a finished flow left at Done would bounce
// the next "Add account" straight back out — and would reuse this
// account's username and app password for the next one.
viewModel.onStartOver()
}
// Custom Tabs needs three things beyond launchUrl: the <queries> entry in the
// manifest (or provider detection silently finds nothing on API 30+), a
// fallback for devices with no Custom Tabs browser at all — realistic on
// GrapheneOS, CalyxOS and plain AOSP, which is disproportionately this app's
// audience — and an explicit way back, since a dismissed tab returns nothing.
LaunchedEffect(state.openInBrowser) {
val url = state.openInBrowser ?: return@LaunchedEffect
val uri = url.toString().toUri()
// ⚠️ Neither failure may escape, and neither may be ignored. An
// exception out of a LaunchedEffect takes the app down — and the outer
// catch named only ActivityNotFoundException, so a SecurityException
// from a locked-down profile did exactly that. Reporting the failure is
// the other half: clearing openInBrowser regardless left the user on
// "waiting for your browser" with a spinner and no browser, on the very
// devices this fallback exists for.
val launched = runCatching {
CustomTabsIntent.Builder().build().launchUrl(context, uri)
}.recoverCatching {
context.startActivity(Intent(Intent.ACTION_VIEW, uri))
}.isSuccess
if (launched) viewModel.onBrowserLaunched() else viewModel.onBrowserUnavailable()
}
// Backing out abandons a flow that may still be polling the server every two
// seconds for the rest of its twenty-minute window.
val abandon = {
viewModel.onStartOver()
onBack()
}
// One action, two ways to ask for it: step back inside the flow, and fall
// out of it only when there is nowhere left to go.
val goBack = { if (!viewModel.onBackWithin()) abandon() }
// ⚠️ The system gesture has to be caught here, not left to the host. The
// hosts only put the section away — `SettingsScreen` sets `section = parent`
// — while this ViewModel is scoped to the Settings back-stack entry and
// outlives that. So a gesture that fell through left the flow *loaded*:
// reopening "Add account" landed back on the previous server's list picker,
// still holding its username, password and collections. Worse in
// `WaitingForBrowser`, where the two-second poll kept running with no screen
// attached for the rest of the twenty-minute window, and a password minted
// after that point was held unrevoked until Settings itself was left.
// Consuming it means [AddAccountViewModel.onStartOver] runs on every exit.
BackHandler(onBack = goBack)
OnboardingScaffold(
hero = { StepHero(state) },
// Grouped rows and fields carry their own inset, so the column adds none.
contentPadding = 0.dp,
topSpacing = OnboardingSpace.md,
progress = {
val position = state.step.position(state.hasSetupStep)?.plus(stepOffset)
val total = totalSteps ?: state.totalSteps
if (position != null) {
OnboardingProgress(
step = position,
total = total,
label = stringResource(R.string.add_account_step_of, position, total),
)
}
},
navigationIcon = {
IconButton(onClick = goBack) {
Icon(
Icons.AutoMirrored.Rounded.ArrowBack,
contentDescription = stringResource(R.string.back),
)
}
},
actions = { StepActions(state, viewModel) },
) {
val fatal = state.fatal
if (fatal != null) {
Text(
fatal.text(),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier = Modifier.padding(horizontal = 24.dp),
)
return@OnboardingScaffold
}
StepTitle(state.step)
when (val step = state.step) {
is AddAccountStep.ChooseProvider -> ProviderStep(step, viewModel)
is AddAccountStep.PrepareAccess -> SetupStep(step)
is AddAccountStep.EnterAddress -> AddressStep(step, viewModel)
is AddAccountStep.Working -> WorkingStep(step)
is AddAccountStep.EnterCredentials -> CredentialsStep(step, viewModel)
is AddAccountStep.ConfirmBrowser -> ConfirmBrowserStep(step)
is AddAccountStep.WaitingForBrowser -> BrowserStep(step)
is AddAccountStep.ChooseLists -> ListsStep(step, viewModel)
is AddAccountStep.Summary -> SummaryStep(step)
AddAccountStep.Done -> Unit
}
// What is known about the chosen service, named before the attempt rather
// than after a 401 the user cannot act on.
//
// ⚠️ Not on the errand step — that step *is* this, and drawing it again
// underneath would print the same instructions twice. On the address step
// it is the fallback for the one route that skips the errand: picking
// "Other server" and then typing an address that turns out to be a
// Fastmail or iCloud one.
val note = when (state.step) {
is AddAccountStep.ChooseProvider, is AddAccountStep.EnterAddress -> state.quirk
else -> null
}
note?.let {
Spacer(Modifier.height(OnboardingSpace.md))
QuirkGuidance(it)
}
// Learned during the browser step and shown from there on, whichever
// step follows: the address it blames is one the user has to go and
// correct on the server, and it is just as true once the lists load.
state.originMismatch?.let { mismatch ->
QuirkNote(
text = stringResource(
R.string.add_account_origin_mismatch,
mismatch.actual,
mismatch.expected,
),
)
}
}
}
/**
* Which of the flow's visible steps this is, or null for one that has no place.
*
* [hasSetup] shifts everything after the errand down by one, because the errand
* is a step of its own rather than a screen borrowing the address's number.
*/
private fun AddAccountStep.position(hasSetup: Boolean): Int? {
val errand = if (hasSetup) 1 else 0
return when (this) {
is AddAccountStep.ChooseProvider -> 1
is AddAccountStep.PrepareAccess -> 2
is AddAccountStep.EnterAddress -> 2 + errand
// One slot, because only one of the three ever happens: a server either
// hands the sign-in to a browser — asking first, where the URL needs
// confirming — or asks for a password.
is AddAccountStep.EnterCredentials,
is AddAccountStep.ConfirmBrowser,
is AddAccountStep.WaitingForBrowser,
-> 3 + errand
is AddAccountStep.ChooseLists -> 4 + errand
// The receipt keeps the bar full rather than dropping it: the flow is
// finished, and a chrome that vanishes on the last screen reads as a
// step lost rather than a step done.
is AddAccountStep.Summary -> ADD_ACCOUNT_STEPS + errand
// Working is a moment inside whichever step spawned it, and Done is gone
// before it draws — neither is a place the user can be.
is AddAccountStep.Working, AddAccountStep.Done -> null
}
}
/**
* The mark for the step, in the family's tonal circle.
*
* Once a service has been chosen it wears *that service's* logo instead — the
* same mark the accounts list will show — so the flow keeps saying what is being
* set up rather than restating that an account is being added.
*/
@Composable
private fun StepHero(state: AddAccountUiState) {
if (state.step is AddAccountStep.Summary) {
ProviderLogo(state.step.provider, size = 72.dp)
return
}
val chosen = (state.step as? AddAccountStep.EnterAddress)?.choice?.provider
?: (state.step as? AddAccountStep.PrepareAccess)?.choice?.provider
if (chosen != null && state.fatal == null) {
ProviderLogo(chosen, size = 72.dp)
return
}
val icon = when {
state.fatal != null -> Icons.Rounded.CloudOff
state.step is AddAccountStep.EnterCredentials -> Icons.Rounded.Lock
// The warning is the screen, so it is the mark too — the browser icon
// would say "this is going fine", which is the opposite of the point.
state.step is AddAccountStep.ConfirmBrowser -> Icons.Rounded.Warning
state.step is AddAccountStep.WaitingForBrowser -> Icons.Rounded.OpenInBrowser
state.step is AddAccountStep.ChooseLists -> Icons.Rounded.Checklist
else -> Icons.Rounded.CloudSync
}
Box(
modifier = Modifier
.size(72.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.secondaryContainer),
contentAlignment = Alignment.Center,
) {
Icon(
icon,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSecondaryContainer,
modifier = Modifier.size(34.dp),
)
}
}
/** Title and one line of explanation, centred above whatever the step asks for. */
@Composable
private fun StepTitle(step: AddAccountStep) {
val (title, body) = when (step) {
is AddAccountStep.ChooseProvider ->
R.string.add_account_provider_title to R.string.add_account_provider_body
is AddAccountStep.PrepareAccess ->
step.quirk.setupTitle to R.string.add_account_setup_body
is AddAccountStep.EnterAddress -> if (step.choice.provider?.hosted == true) {
R.string.add_account_email_title to R.string.add_account_email_body
} else {
R.string.add_account_server_title to R.string.add_account_server_body
}
is AddAccountStep.EnterCredentials ->
R.string.add_account_credentials_title to R.string.add_account_credentials_body
is AddAccountStep.ConfirmBrowser ->
R.string.add_account_browser_confirm_title to R.string.add_account_browser_confirm_body
is AddAccountStep.ChooseLists ->
R.string.add_account_lists_title to R.string.add_account_lists_body
is AddAccountStep.Summary ->
R.string.add_account_summary_title to R.string.add_account_summary_body
else -> return
}
Text(
stringResource(title),
style = MaterialTheme.typography.headlineSmall,
textAlign = TextAlign.Center,
modifier = Modifier.padding(horizontal = 24.dp),
)
Spacer(Modifier.height(8.dp))
Text(
stringResource(body),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier = Modifier.padding(horizontal = 24.dp),
)
Spacer(Modifier.height(OnboardingSpace.lg))
}
/**
* The step's call to action, pinned at the bottom the way the onboarding shell
* puts it.
*
* One primary action per step, always in the same place, so the flow reads as
* one screen advancing rather than five different ones. A step that commits
* nothing renders no button rather than a disabled one.
*/
@Composable
private fun ColumnScope.StepActions(state: AddAccountUiState, viewModel: AddAccountViewModel) {
if (state.fatal != null) {
PrimaryAction(
label = stringResource(R.string.add_account_start_over),
enabled = true,
onClick = viewModel::onStartOver,
)
return
}
when (val step = state.step) {
// ⚠️ No action at all. Tapping a row *is* the choice and advances on the
// spot, so a Continue here would be a second press for the same decision
// — and a disabled one, before anything is picked, that looks like the
// screen is broken.
is AddAccountStep.ChooseProvider -> Unit
is AddAccountStep.PrepareAccess -> PrimaryAction(
label = stringResource(R.string.add_account_continue),
enabled = true,
onClick = viewModel::onSetupAcknowledged,
)
is AddAccountStep.EnterAddress -> PrimaryAction(
label = stringResource(R.string.add_account_continue),
enabled = step.input.isNotBlank(),
onClick = viewModel::onAddressSubmitted,
)
is AddAccountStep.EnterCredentials -> PrimaryAction(
label = stringResource(R.string.add_account_sign_in),
enabled = step.username.isNotBlank() && step.password.isNotEmpty(),
onClick = viewModel::onCredentialsSubmitted,
)
is AddAccountStep.ChooseLists -> PrimaryAction(
// The count is the feedback: a disabled button with no explanation
// is the commonest way a picker looks broken.
label = if (step.selected.isEmpty()) {
stringResource(R.string.add_account_no_lists_selected)
} else {
stringResource(R.string.add_account_save)
},
enabled = step.selected.isNotEmpty(),
onClick = viewModel::onSave,
)
is AddAccountStep.Summary -> PrimaryAction(
label = stringResource(R.string.add_account_summary_done),
enabled = true,
onClick = viewModel::onSummaryDone,
)
// The way forward stays primary: both causes are legitimate behind a
// reverse proxy, and refusing outright would make Login Flow v2 unusable
// for a large share of self-hosted installs. The password route is the
// secondary, exactly as it is one step later.
is AddAccountStep.ConfirmBrowser -> {
PrimaryAction(
label = stringResource(R.string.add_account_browser_open),
enabled = true,
onClick = viewModel::onBrowserConfirmed,
)
TextButton(
onClick = viewModel::onBrowserCancelled,
modifier = Modifier.fillMaxWidth(),
) { Text(stringResource(R.string.add_account_browser_use_password)) }
}
is AddAccountStep.WaitingForBrowser -> {
// A failed flow is retried by default. With no browser a retry
// cannot help; while the server throttles or is in maintenance an
// immediate one only adds to it, so a password comes first.
val usePassword = @Composable { primary: Boolean ->
if (primary) {
PrimaryAction(
label = stringResource(R.string.add_account_browser_use_password),
enabled = true,
onClick = viewModel::onBrowserCancelled,
)
} else {
TextButton(onClick = viewModel::onBrowserCancelled, modifier = Modifier.fillMaxWidth()) {
Text(stringResource(R.string.add_account_browser_use_password))
}
}
}
val retry = @Composable { primary: Boolean ->
if (primary) {
PrimaryAction(
label = stringResource(R.string.add_account_browser_retry),
enabled = true,
onClick = viewModel::onBrowserRetry,
)
} else {
TextButton(onClick = viewModel::onBrowserRetry, modifier = Modifier.fillMaxWidth()) {
Text(stringResource(R.string.add_account_browser_retry))
}
}
}
when (step.error) {
null -> usePassword(false)
AddAccountMessage.BrowserUnavailable -> usePassword(true)
AddAccountMessage.BrowserRateLimited, AddAccountMessage.BrowserMaintenance -> {
usePassword(true)
retry(false)
}
else -> {
retry(true)
usePassword(false)
}
}
}
is AddAccountStep.Working, AddAccountStep.Done -> Unit
}
}
@@ -0,0 +1,78 @@
package de.jeanlucmakiola.agendula.ui.accounts.add
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import de.jeanlucmakiola.agendula.R
import de.jeanlucmakiola.caldav.CalDavDiscovery
import de.jeanlucmakiola.caldav.ServerQuirk
/**
* A discovery failure, in words rather than a status code.
*
* ⚠️ The server's own message never reaches the screen. The most common failure
* here is a **405** — what an ordinary web server answers to `PROPFIND`, and so
* what someone typing their *website* instead of their CalDAV address gets — and
* "HTTP 405 Method Not Allowed" tells them nothing they can act on. It is also
* untranslatable, and frequently not in a language they read.
*/
internal val CalDavDiscovery.Outcome.Cause.message: Int
get() = when (this) {
CalDavDiscovery.Outcome.Cause.NOT_AN_ADDRESS -> R.string.add_account_error_not_an_address
CalDavDiscovery.Outcome.Cause.NOT_A_DAV_SERVER -> R.string.add_account_error_not_dav
CalDavDiscovery.Outcome.Cause.NO_CALENDAR_SUPPORT -> R.string.add_account_error_no_calendar
CalDavDiscovery.Outcome.Cause.UNREACHABLE -> R.string.add_account_error_unreachable
CalDavDiscovery.Outcome.Cause.INSECURE -> R.string.add_account_error_insecure
CalDavDiscovery.Outcome.Cause.NO_CALENDARS -> R.string.add_account_error_no_calendars
CalDavDiscovery.Outcome.Cause.SERVER_ERROR -> R.string.add_account_error_server
}
/**
* The same rule as [message], for the messages the flow itself produces.
*
* Kept here rather than on [AddAccountMessage] so the type stays a plain Kotlin
* one and the resource ids stay where the resources are.
*/
@Composable
internal fun AddAccountMessage.text(): String = when (this) {
AddAccountMessage.Progress.Discovering ->
stringResource(R.string.add_account_working_discovering)
AddAccountMessage.Progress.SigningIn ->
stringResource(R.string.add_account_working_signing_in)
AddAccountMessage.Progress.ReadingLists ->
stringResource(R.string.add_account_working_reading_lists)
AddAccountMessage.Progress.Saving ->
stringResource(R.string.add_account_working_saving)
AddAccountMessage.GoogleUnsupported ->
stringResource(R.string.add_account_error_google_unsupported)
AddAccountMessage.AlreadyExists ->
stringResource(R.string.add_account_error_already_exists)
AddAccountMessage.ExternalStorage ->
stringResource(R.string.accounts_external_storage)
AddAccountMessage.NoUsableLists ->
stringResource(R.string.add_account_error_no_usable_lists)
AddAccountMessage.NotSaved -> stringResource(R.string.add_account_error_not_saved)
AddAccountMessage.KeystoreRefused -> stringResource(R.string.add_account_error_keystore)
AddAccountMessage.CredentialsRejected ->
stringResource(R.string.add_account_error_credentials_rejected)
AddAccountMessage.BrowserApprovalExpired ->
stringResource(R.string.add_account_browser_error_expired)
AddAccountMessage.BrowserRateLimited ->
stringResource(R.string.add_account_browser_error_rate_limited)
AddAccountMessage.BrowserMaintenance ->
stringResource(R.string.add_account_browser_error_maintenance)
AddAccountMessage.BrowserFailed ->
stringResource(R.string.add_account_browser_error_failed)
AddAccountMessage.BrowserUnavailable ->
stringResource(R.string.add_account_browser_error_unavailable)
is AddAccountMessage.OutsideCredentialScope ->
stringResource(R.string.add_account_error_cross_domain, host)
is AddAccountMessage.Quirk -> when (quirk) {
ServerQuirk.FASTMAIL_APP_PASSWORD ->
stringResource(R.string.add_account_quirk_hint_fastmail)
ServerQuirk.ICLOUD_APP_SPECIFIC_PASSWORD ->
stringResource(R.string.add_account_quirk_hint_icloud)
// Neither reaches a credentials step: Google is refused before it, and
// the brute-force note is a pre-flight warning.
else -> ""
}
}
@@ -0,0 +1,43 @@
package de.jeanlucmakiola.agendula.ui.accounts.add
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.agendula.R
/**
* Step 2: the one address the chosen service actually needs.
*
* A hosted service is reached by the email address the user already knows, so it
* asks for that and nothing else; a server they run is reached by a URL only
* they have. The old single field had to ask for either, which is why its hint
* had to show both.
*/
@Composable
internal fun AddressStep(step: AddAccountStep.EnterAddress, viewModel: AddAccountViewModel) {
val hosted = step.choice.provider?.hosted == true
Column(Modifier.padding(horizontal = 16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
FieldRow(
label = if (hosted) {
stringResource(R.string.add_account_email_label)
} else {
stringResource(R.string.add_account_server_label)
},
value = step.input,
onValueChange = viewModel::onAddressChanged,
placeholder = step.choice.provider?.primaryDomain?.let { "you@$it" }
?: stringResource(R.string.add_account_server_hint),
error = step.error?.let { stringResource(it.message) },
// An email address is still typed with the URI keyboard: it is the
// one that carries "@" and "." without a shift, and the field takes
// a URL too whenever a hosted service is reached by one.
keyboardType = KeyboardType.Uri,
onImeAction = viewModel::onAddressSubmitted,
)
}
}
@@ -0,0 +1,36 @@
package de.jeanlucmakiola.agendula.ui.accounts.add
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.agendula.R
import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.SelectedCheck
import de.jeanlucmakiola.floret.components.positionOf
/** Step 4: which of the account's collections to keep in sync. */
@Composable
internal fun ListsStep(step: AddAccountStep.ChooseLists, viewModel: AddAccountViewModel) {
// The heading is the shell's; this step only lists.
step.collections.forEachIndexed { index, collection ->
val checked = collection.url in step.selected
GroupedRow(
title = collection.displayName ?: collection.url.encodedPath,
summary = when {
collection.readOnly -> stringResource(R.string.add_account_lists_read_only)
collection.isShared -> stringResource(R.string.add_account_lists_shared)
else -> null
},
position = positionOf(index, step.collections.size),
selected = checked,
// The family marks a chosen row with a check, not a Material
// checkbox — same affordance every picker in the app uses.
trailing = { if (checked) SelectedCheck() },
onClick = { viewModel.onListToggled(collection.url) },
)
}
Spacer(Modifier.height(16.dp))
}
@@ -0,0 +1,73 @@
package de.jeanlucmakiola.agendula.ui.accounts.add
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.agendula.R
import de.jeanlucmakiola.agendula.ui.accounts.ProviderLogo
import de.jeanlucmakiola.caldav.CalDavProvider
import de.jeanlucmakiola.caldav.ServerQuirk
import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.GroupedSectionHeader
import de.jeanlucmakiola.floret.components.Position
import de.jeanlucmakiola.floret.components.SelectedCheck
import de.jeanlucmakiola.floret.components.positionOf
/**
* Step 1: which service the tasks live on.
*
* The services we know by name, each by its own mark, and then the escape hatch
* for everything else. Choosing here is what lets step 2 ask one clear question
* instead of "an email address, or a server address, whichever you have" — and
* it is what puts a provider's app-password rule in front of the user *before*
* the 401 rather than after it.
*/
@Composable
internal fun ProviderStep(step: AddAccountStep.ChooseProvider, viewModel: AddAccountViewModel) {
val services = CalDavProvider.selectable
services.forEachIndexed { index, provider ->
val choice = ProviderChoice.Service(provider)
val chosen = step.choice == choice
// ⚠️ On the row, not behind a tap. Google is listed because people come
// looking for it — an absent row reads as the app being unfinished
// rather than as Google's own limitation — but a row that only reveals
// why it cannot be used *after* being selected is the worst of both: it
// looks available, then quietly refuses.
val unusable = ServerQuirk.forProvider(provider)?.isFatal == true
GroupedRow(
title = provider.label,
summary = when {
unusable -> stringResource(R.string.add_account_provider_unsupported)
else -> provider.primaryDomain
?: stringResource(R.string.add_account_provider_self_hosted)
},
position = positionOf(index, services.size),
selected = chosen && !unusable,
dimmed = unusable,
leading = { ProviderLogo(provider) },
trailing = { if (chosen && !unusable) SelectedCheck() },
// Still tappable: the summary is the short reason, and the note the
// tap brings up is the long one.
onClick = { viewModel.onProviderChosen(choice) },
)
}
GroupedSectionHeader(stringResource(R.string.add_account_provider_other_header))
val other = ProviderChoice.OtherServer
val otherChosen = step.choice == other
GroupedRow(
title = stringResource(R.string.add_account_provider_other),
summary = stringResource(R.string.add_account_provider_other_summary),
position = Position.Alone,
selected = otherChosen,
// Null is the family's "a server, unnamed" mark — exactly what this row
// is choosing.
leading = { ProviderLogo(provider = null) },
trailing = { if (otherChosen) SelectedCheck() },
onClick = { viewModel.onProviderChosen(other) },
)
Spacer(Modifier.height(16.dp))
}
@@ -0,0 +1,56 @@
package de.jeanlucmakiola.agendula.ui.accounts.add
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringArrayResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.agendula.R
import de.jeanlucmakiola.caldav.ServerQuirk
import de.jeanlucmakiola.floret.components.InstructionSteps
/**
* Step 2, first half: the errand the chosen service requires, before anything is
* typed.
*
* A screen of its own rather than a note under the address field. What it asks
* for happens somewhere else entirely — open a browser, sign in to the provider,
* mint an app password, come back — so it has to be read *before* the fields it
* makes answerable, and it needs the room to be four numbered actions rather
* than a paragraph. Underneath an input, it was neither.
*
* The list carries no header of its own: the step's headline already names the
* requirement, and repeating it above the rows says it twice.
*/
@Composable
internal fun SetupStep(step: AddAccountStep.PrepareAccess) {
InstructionSteps(
steps = stringArrayResource(step.quirk.steps).asList(),
footnote = stringResource(step.quirk.footnote),
)
Spacer(Modifier.height(16.dp))
}
/** The headline for the errand — what the service requires, in one line. */
internal val ServerQuirk.setupTitle: Int
get() = when (this) {
ServerQuirk.FASTMAIL_APP_PASSWORD -> R.string.add_account_setup_fastmail_title
else -> R.string.add_account_setup_icloud_title
}
private val ServerQuirk.steps: Int
get() = when (this) {
ServerQuirk.FASTMAIL_APP_PASSWORD -> R.array.add_account_setup_fastmail_steps
// Only two quirks reach this screen — ServerQuirk.hasSetupSteps is what
// decides — so the branch that cannot happen takes the other one rather
// than inventing a third set of instructions to be wrong with.
else -> R.array.add_account_setup_icloud_steps
}
private val ServerQuirk.footnote: Int
get() = when (this) {
ServerQuirk.FASTMAIL_APP_PASSWORD -> R.string.add_account_setup_fastmail_footnote
else -> R.string.add_account_setup_icloud_footnote
}
@@ -0,0 +1,167 @@
package de.jeanlucmakiola.agendula.ui.accounts.add
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.CloudOff
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.agendula.R
import de.jeanlucmakiola.floret.components.Position
/**
* Step 3a: a username and a password.
*
* The generic CalDAV route, taken whenever the server has no browser sign-in to
* hand off to.
*/
@Composable
internal fun CredentialsStep(
step: AddAccountStep.EnterCredentials,
viewModel: AddAccountViewModel,
) {
Column(Modifier.padding(horizontal = 16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
// Two fields in one connected group, which is what the family does with
// a pair that is filled in together.
Column {
FieldRow(
label = stringResource(R.string.add_account_username_label),
value = step.username,
onValueChange = viewModel::onUsernameChanged,
position = Position.Top,
)
FieldRow(
label = stringResource(R.string.add_account_password_label),
value = step.password,
onValueChange = viewModel::onPasswordChanged,
position = Position.Bottom,
error = step.error?.text(),
hint = stringResource(R.string.add_account_password_hint),
keyboardType = KeyboardType.Password,
onImeAction = viewModel::onCredentialsSubmitted,
)
}
}
}
/**
* Step 3b, first half: what is wrong with the page we are about to open.
*
* ⚠️ A screen of its own, drawn *before* the Custom Tab rather than behind it.
* The login URL is where the account password is typed, so a warning about it
* has to be readable at the moment it still means something — and the primary
* action stays the way forward, because both causes are legitimate on real
* deployments and refusing outright would make Login Flow v2 unusable behind an
* ordinary reverse proxy.
*/
@Composable
internal fun ColumnScope.ConfirmBrowserStep(step: AddAccountStep.ConfirmBrowser) {
// Title and body come from StepTitle like every other fixed step's do; what
// is left here is the cause, which is the whole reason the step exists.
step.hostMismatch?.let { mismatch ->
QuirkNote(
text = stringResource(
R.string.add_account_browser_host_mismatch,
mismatch.actual,
mismatch.expected,
),
)
}
// Both can be true at once — a proxy that rewrites the host and drops TLS
// is one misconfiguration, not two — and each is worth its own sentence.
if (step.insecure) {
Spacer(Modifier.height(16.dp))
QuirkNote(text = stringResource(R.string.add_account_browser_insecure))
}
}
/**
* Step 3b: the server is signing the user in, in a browser we do not own.
*
* The same slot as [CredentialsStep] because only one of the two ever happens —
* but its own title, mark and body, since "approve this in your browser" and
* "type your password" have nothing in common but their place in the flow.
*/
@Composable
internal fun ColumnScope.BrowserStep(step: AddAccountStep.WaitingForBrowser) {
// ⚠️ The title changes too. Swapping only the body left every failure in
// this phase — a burnt credential, a 429, a maintenance page — sitting
// under the heading "Waiting for your browser", which reads as "still
// working" when nothing is working and nothing else will happen.
Message(
icon = {
if (step.error == null) {
CircularProgressIndicator(Modifier.size(24.dp))
} else {
Icon(Icons.Rounded.CloudOff, contentDescription = null)
}
},
title = if (step.error == null) {
stringResource(R.string.add_account_browser_title)
} else {
stringResource(R.string.add_account_browser_failed_title)
},
text = step.error?.text() ?: stringResource(R.string.add_account_browser_body),
)
// ⚠️ A sibling of the message, not a child of its padded column: QuirkNote
// carries the grouped-list inset itself, and nesting it inside one that
// already pads by the same amount insets it twice.
step.hostMismatch?.let { mismatch ->
Spacer(Modifier.height(16.dp))
QuirkNote(
text = stringResource(
R.string.add_account_browser_host_mismatch,
mismatch.actual,
mismatch.expected,
),
)
}
}
@Composable
internal fun WorkingStep(step: AddAccountStep.Working) {
Column(
Modifier.fillMaxWidth().padding(32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
CircularProgressIndicator()
Text(step.message.text(), style = MaterialTheme.typography.bodyLarge)
}
}
@Composable
private fun Message(
icon: @Composable () -> Unit,
text: String,
title: String? = null,
) {
Column(
Modifier.fillMaxWidth().padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
icon()
title?.let { Text(it, style = MaterialTheme.typography.titleMedium) }
Text(
text,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
@@ -0,0 +1,81 @@
package de.jeanlucmakiola.agendula.ui.accounts.add
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Check
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.agendula.R
import de.jeanlucmakiola.agendula.ui.accounts.ProviderLogo
import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.GroupedSectionHeader
import de.jeanlucmakiola.floret.components.positionOf
/**
* The receipt: what the flow just set up, under the name the accounts list will
* use for it.
*
* Not a confirmation — the account is already saved, and there is nothing here
* to undo. It exists because the wizard used to vanish on success, leaving "did
* it take the right lists, and under which name?" answerable only by going and
* finding the account again.
*/
@Composable
internal fun SummaryStep(step: AddAccountStep.Summary) {
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
ProviderLogo(step.provider, size = 48.dp)
Spacer(Modifier.width(16.dp))
Column {
Text(step.title, style = MaterialTheme.typography.titleMedium)
// The account's own two supporting facts, in the order the accounts
// list gives them: what it is, then who you are on it.
listOfNotNull(step.secondary, step.username.takeIf { it.isNotBlank() })
.forEach {
Text(
it,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
Spacer(Modifier.height(24.dp))
GroupedSectionHeader(
pluralStringResource(
R.plurals.add_account_summary_lists,
step.lists.size,
step.lists.size,
),
)
step.lists.forEachIndexed { index, name ->
GroupedRow(
title = name,
position = positionOf(index, step.lists.size),
leading = {
Icon(
Icons.Rounded.Check,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
)
},
)
}
Spacer(Modifier.height(16.dp))
}
@@ -0,0 +1,35 @@
package de.jeanlucmakiola.agendula.ui.onboarding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.agendula.ui.accounts.add.ADD_ACCOUNT_STEPS
import de.jeanlucmakiola.agendula.ui.accounts.add.AddAccountScreen
import de.jeanlucmakiola.agendula.ui.accounts.add.AddAccountViewModel
/** The inline wizard's shortest length, before a provider is picked. */
internal const val ACCOUNT_STEP_SLOTS = ADD_ACCOUNT_STEPS
/** [OnboardingStep.Account]: the add-account wizard, given the whole screen. */
@Composable
internal fun AccountStep(state: OnboardingUiState, viewModel: OnboardingViewModel) {
// The same instance the wizard resolves for itself, so its length can be
// read here without keeping a second copy of its state.
val account: AddAccountViewModel = hiltViewModel()
val accountState by account.state.collectAsStateWithLifecycle()
// ⚠️ The wizard grows a step for a provider that needs an app password
// minted first, and the outer bar has to grow with it — otherwise first
// run counts one flow while the screen inside it counts another.
LaunchedEffect(accountState.totalSteps) {
viewModel.onAccountStepsChanged(accountState.totalSteps)
}
AddAccountScreen(
onDone = { viewModel.onAccountFinished(added = true) },
onBack = { viewModel.onAccountFinished(added = false) },
stepOffset = state.position - 1,
totalSteps = state.total,
viewModel = account,
)
}
@@ -0,0 +1,78 @@
package de.jeanlucmakiola.agendula.ui.settings
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import de.jeanlucmakiola.agendula.data.tasks.room.AccountEntity
import de.jeanlucmakiola.agendula.ui.accounts.AccountDetailScreen
import de.jeanlucmakiola.agendula.ui.accounts.AccountsScreen
import de.jeanlucmakiola.agendula.ui.accounts.AccountsViewModel
import de.jeanlucmakiola.agendula.ui.accounts.add.AddAccountScreen
import de.jeanlucmakiola.agendula.ui.accounts.add.AddAccountViewModel
/** Settings → Accounts and the screens under it, layered over the hub. */
@Composable
internal fun AccountSections(
section: SettingsSection?,
initialAccountId: Long?,
onSection: (SettingsSection?) -> Unit,
) {
// Hoisted so the add flow can refresh the list it returns to.
val accountsViewModel: AccountsViewModel = hiltViewModel()
// Shared with the add flow's own lookup, so "sign in again" can prefill it.
val addAccountViewModel: AddAccountViewModel = hiltViewModel()
val signInAgain: (AccountEntity) -> Unit = { account ->
addAccountViewModel.startReauthentication(account.id, account.principalUrl, account.username)
onSection(SettingsSection.AddAccount)
}
// Which account the detail screen is showing; the section alone cannot say.
var openAccount by rememberSaveable(initialAccountId) { mutableStateOf(initialAccountId) }
// Accounts stays composed under Add account, for the same reason Storage
// stays composed under Export: the deeper screen slides over it.
val accountsOpen = section == SettingsSection.Accounts ||
section?.parent == SettingsSection.Accounts
SlideInSection(visible = accountsOpen) {
AccountsScreen(
onAddAccount = {
// A sign-in abandoned half-way must not turn this into one.
if (addAccountViewModel.state.value.reauthenticating) addAccountViewModel.onStartOver()
onSection(SettingsSection.AddAccount)
},
onSignInAgain = signInAgain,
onOpenAccount = {
openAccount = it
onSection(SettingsSection.Account)
},
onOpenStorage = { onSection(SettingsSection.Storage) },
onBack = { onSection(null) },
viewModel = accountsViewModel,
)
}
SlideInSection(visible = section == SettingsSection.Account) {
openAccount?.let { id ->
AccountDetailScreen(
accountId = id,
onBack = { onSection(SettingsSection.Accounts) },
onRemoved = { onSection(SettingsSection.Accounts) },
onSignInAgain = signInAgain,
onOpenStorage = { onSection(SettingsSection.Storage) },
viewModel = accountsViewModel,
)
}
}
SlideInSection(visible = section == SettingsSection.AddAccount) {
AddAccountScreen(
// The list stays composed underneath, so nothing re-runs its
// init and OnResume never fires on a section change. It no longer
// needs to: the accounts come from an observed query, so a new
// account — and every later sync — arrives on its own.
onDone = { onSection(SettingsSection.Accounts) },
onBack = { onSection(SettingsSection.Accounts) },
viewModel = addAccountViewModel,
)
}
}
+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" />
@@ -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. Without this file
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
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" />