sync(chunk 2d): the account-add flow

One flow, one back-stack entry, as a stepper rather than four destinations —
the steps are not independently reachable, and "back" from the browser step
abandons a server-side flow rather than popping a screen. It hangs off Settings
with the same sliding-section pattern Storage -> Export uses.

address -> discovery -> (Nextcloud browser approval | username + password)
        -> pick lists -> add account

Provider warnings come before the attempt, not after: type a Fastmail or iCloud
address and the app password rule is stated while you type, which is the single
most common support ticket a CalDAV client inherits. Google is refused with the
reason. A login-flow host mismatch is shown, not refused — reverse proxies are
ordinary on self-hosted installs.

PreemptiveBasicInterceptor is deleted. The vendored BasicDigestAuthHandler
already sends Basic preemptively over HTTPS, also does Digest (Baikal defaults
to it, OkHttp has none), caches the working scheme, and scopes by registrable
domain — which is what iCloud's cross-host home set needs. Two implementations
of one job is the defect chunk 1 removed from ICalendarWriter.

⚠️ That handler compares its `domain` against the *registrable* domain, so
passing the full host meant credentials were withheld from every request to
every subdomain — i.e. every self-hosted Nextcloud, silently 401ing forever.
Pinned by CalDavHttpTest.

CalDavGateway and AccountCreator put the network and the database behind
interfaces so the sign-in state machine is testable without a server, a
database, a Keystore or an AccountManager. It had no tests, and the review
found eight issues in it.

Account creation is transactional and rolls its lists back explicitly:
account_id is ON DELETE SET NULL, so deleting the row alone leaves orphan
lists behind and every retry adds another set.
This commit is contained in:
2026-09-04 18:37:16 +02:00
parent ec50e0998c
commit 48fc7261f0
22 changed files with 1869 additions and 161 deletions
+2
View File
@@ -197,6 +197,8 @@ dependencies {
// hilt-work supplies the HiltWorkerFactory; its compiler generates the // hilt-work supplies the HiltWorkerFactory; its compiler generates the
// @HiltWorker plumbing. // @HiltWorker plumbing.
implementation(libs.androidx.work.runtime.ktx) implementation(libs.androidx.work.runtime.ktx)
// Custom Tabs: the Nextcloud login flow hands the browser an approval page.
implementation(libs.androidx.browser)
implementation(libs.androidx.hilt.work) implementation(libs.androidx.hilt.work)
ksp(libs.androidx.hilt.compiler) ksp(libs.androidx.hilt.compiler)
+6
View File
@@ -56,6 +56,12 @@
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
</intent> </intent>
<!-- 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> </queries>
<application <application
@@ -12,6 +12,10 @@ import dagger.Provides
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent import dagger.hilt.components.SingletonComponent
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.OkHttpCalDavGateway
import de.jeanlucmakiola.agendula.data.tasks.AndroidProviderEnvironment import de.jeanlucmakiola.agendula.data.tasks.AndroidProviderEnvironment
import de.jeanlucmakiola.agendula.data.tasks.AndroidTasksDataSource import de.jeanlucmakiola.agendula.data.tasks.AndroidTasksDataSource
import de.jeanlucmakiola.agendula.data.tasks.ModeRoutingTasksDataSource import de.jeanlucmakiola.agendula.data.tasks.ModeRoutingTasksDataSource
@@ -55,6 +59,14 @@ abstract class DataBindModule {
@Binds @Binds
@Singleton @Singleton
abstract fun bindProviderEnvironment(impl: AndroidProviderEnvironment): ProviderEnvironment abstract fun bindProviderEnvironment(impl: AndroidProviderEnvironment): ProviderEnvironment
@Binds
@Singleton
abstract fun bindCalDavGateway(impl: OkHttpCalDavGateway): CalDavGateway
@Binds
@Singleton
abstract fun bindAccountCreator(impl: AccountRepository): AccountCreator
} }
@Module @Module
@@ -0,0 +1,161 @@
package de.jeanlucmakiola.agendula.data.sync
import de.jeanlucmakiola.agendula.data.di.IoDispatcher
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.CoroutineDispatcher
import kotlinx.coroutines.withContext
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>,
): 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,
@IoDispatcher private val io: CoroutineDispatcher,
) : AccountCreator {
/** What went wrong, in words a user can act on. */
sealed interface Outcome {
data class Created(val accountId: Long) : Outcome
data object AlreadyExists : Outcome
data class CredentialFailed(val reason: String) : Outcome
}
suspend fun all(): List<AccountEntity> = withContext(io) { database.accounts().all() }
/**
* 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>,
): Outcome = withContext(io) {
// 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 existsInSystem = accounts.find(displayName) != null
val existsInRoom = database.accounts().all().any { it.displayName == displayName }
if (existsInSystem || existsInRoom) return@withContext Outcome.AlreadyExists
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,
),
)
selected.forEach { collection ->
database.taskLists().insert(
TaskListEntity(
name = collection.displayName ?: collection.url.pathSegments
.lastOrNull { it.isNotEmpty() }
.orEmpty(),
color = collection.color ?: DEFAULT_LIST_COLOR,
accountId = id,
isReadOnly = collection.readOnly,
href = collection.url.toString(),
),
)
}
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)
return@withContext Outcome.CredentialFailed(
"the device keystore would not store the password",
)
}
if (!accounts.add(displayName, accountId)) {
credentials.clear(accountId)
rollback(accountId)
return@withContext Outcome.AlreadyExists
}
Outcome.Created(accountId)
}
/**
* Undoes a half-made account.
*
* ⚠️ The lists 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 leaves a full
* set of orphan device-only lists behind, and every retry adds another.
*/
private fun rollback(accountId: Long) = database.runInTransaction {
database.taskLists().deleteForAccount(accountId)
database.accounts().delete(accountId)
}
/**
* 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) = withContext(io) {
credentials.clear(accountId)
database.accounts().delete(accountId)
accounts.find(displayName)?.let { accounts.remove(it) }
}
private companion object {
/** M3 primary-ish blue; the user recolours a list from its own screen. */
const val DEFAULT_LIST_COLOR = 0xFF4C6FFF.toInt()
}
}
@@ -74,6 +74,18 @@ class CalDavAccounts @Inject constructor(
fun roomAccountId(account: Account): Long? = fun roomAccountId(account: Account): Long? =
accountManager.getUserData(account, KEY_ROOM_ACCOUNT_ID)?.toLongOrNull() 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) { fun requestSync(account: Account) {
ContentResolver.requestSync( ContentResolver.requestSync(
account, account,
@@ -0,0 +1,73 @@
package de.jeanlucmakiola.agendula.data.sync
import de.jeanlucmakiola.agendula.data.di.IoDispatcher
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 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
/** 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()
}
override suspend fun pollLoginFlow(flow: NextcloudLoginFlow.Flow): NextcloudLoginFlow.PollResult =
withContext(io) {
NextcloudLoginFlow(CalDavHttp.anonymous(userAgent), userAgent).poll(flow, now())
}
private fun now() = System.currentTimeMillis() / 1000
}
@@ -48,4 +48,15 @@ interface TaskListDao {
@Query("DELETE FROM task_lists WHERE id = :listId") @Query("DELETE FROM task_lists WHERE id = :listId")
fun delete(listId: Long) fun delete(listId: Long)
/**
* Every list belonging to [accountId].
*
* Only for rolling back a half-created account. Removing a *working* account
* must leave its lists alone — `account_id` is `ON DELETE SET NULL` for that
* reason, and deleting an account is not an instruction to destroy the tasks
* it held.
*/
@Query("DELETE FROM task_lists WHERE account_id = :accountId")
fun deleteForAccount(accountId: Long)
} }
@@ -0,0 +1,125 @@
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.material.icons.Icons
import androidx.compose.material.icons.rounded.Add
import androidx.compose.material.icons.rounded.CloudSync
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
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.stringResource
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.agendula.R
import de.jeanlucmakiola.agendula.data.tasks.room.AccountEntity
import de.jeanlucmakiola.agendula.ui.common.OnResume
import de.jeanlucmakiola.floret.components.CollapsingScaffold
import de.jeanlucmakiola.floret.components.GroupedRow
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,
onBack: () -> Unit,
viewModel: AccountsViewModel,
) {
val accounts by viewModel.accounts.collectAsStateWithLifecycle()
var pendingRemoval by remember { mutableStateOf<AccountEntity?>(null) }
// An account can be removed from system Settings while we are away.
OnResume { viewModel.refresh() }
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 = 16.dp),
)
Spacer(Modifier.height(12.dp))
Text(
stringResource(R.string.accounts_empty_title),
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(horizontal = 16.dp),
)
Spacer(Modifier.height(4.dp))
Text(
stringResource(R.string.accounts_empty_body),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 16.dp),
)
Spacer(Modifier.height(24.dp))
} else {
loaded.forEachIndexed { index, account ->
GroupedRow(
title = account.displayName,
// Never the raw values: lastSyncError is an exception string
// and lastSyncAt renders as an ISO-8601 UTC instant, and both
// bypass strings.xml entirely.
summary = when {
account.lastSyncError != null ->
stringResource(R.string.accounts_sync_failed)
account.lastSyncAt != null -> DateUtils.getRelativeTimeSpanString(
account.lastSyncAt.toEpochMilliseconds(),
System.currentTimeMillis(),
DateUtils.MINUTE_IN_MILLIS,
).toString()
else -> stringResource(R.string.accounts_never_synced)
},
position = positionOf(index, loaded.size),
modifier = Modifier.padding(horizontal = 16.dp),
onClick = { pendingRemoval = account },
)
}
Spacer(Modifier.height(24.dp))
}
GroupedRow(
title = stringResource(R.string.accounts_add),
position = Position.Alone,
modifier = Modifier.padding(horizontal = 16.dp),
leading = { Icon(Icons.Rounded.Add, contentDescription = null) },
onClick = onAddAccount,
)
}
// A plain confirmation, which is the one thing CLAUDE.md still allows an
// AlertDialog for.
pendingRemoval?.let { account ->
AlertDialog(
onDismissRequest = { pendingRemoval = null },
title = { Text(stringResource(R.string.accounts_remove_confirm_title, account.displayName)) },
text = { Text(stringResource(R.string.accounts_remove_confirm_body)) },
confirmButton = {
TextButton(onClick = {
viewModel.remove(account)
pendingRemoval = null
}) { Text(stringResource(R.string.accounts_remove_confirm_action)) }
},
dismissButton = {
TextButton(onClick = { pendingRemoval = null }) {
Text(stringResource(android.R.string.cancel))
}
},
)
}
}
@@ -0,0 +1,38 @@
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.sync.AccountRepository
import de.jeanlucmakiola.agendula.data.tasks.room.AccountEntity
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
class AccountsViewModel @Inject constructor(
private val repository: AccountRepository,
) : ViewModel() {
private val _accounts = MutableStateFlow<List<AccountEntity>?>(null)
/** `null` until the first load, so the empty state does not flash. */
val accounts: StateFlow<List<AccountEntity>?> = _accounts.asStateFlow()
init {
refresh()
}
fun refresh() {
viewModelScope.launch { _accounts.value = repository.all() }
}
fun remove(account: AccountEntity) {
viewModelScope.launch {
repository.remove(account.id, account.displayName)
refresh()
}
}
}
@@ -0,0 +1,321 @@
package de.jeanlucmakiola.agendula.ui.accounts
import android.content.ActivityNotFoundException
import android.content.Intent
import androidx.browser.customtabs.CustomTabsIntent
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.CloudOff
import androidx.compose.material3.Button
import androidx.compose.material3.Checkbox
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
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.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
import androidx.core.net.toUri
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.hilt.navigation.compose.hiltViewModel
import de.jeanlucmakiola.agendula.R
import de.jeanlucmakiola.caldav.ServerQuirk
import de.jeanlucmakiola.floret.components.CollapsingScaffold
import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.GroupedSurface
import de.jeanlucmakiola.floret.components.Position
import de.jeanlucmakiola.floret.components.positionOf
/**
* Adding a CalDAV account: one flow, one back-stack entry.
*
* A stepper rather than four 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.
*/
@Composable
internal fun AddAccountScreen(
onDone: () -> Unit,
onBack: () -> Unit,
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()
try {
CustomTabsIntent.Builder().build().launchUrl(context, uri)
} catch (_: ActivityNotFoundException) {
runCatching { context.startActivity(Intent(Intent.ACTION_VIEW, uri)) }
}
viewModel.onBrowserLaunched()
}
CollapsingScaffold(
title = stringResource(R.string.add_account_title),
// Backing out abandons a flow that may still be polling the server every
// two seconds for the rest of its twenty-minute window.
onBack = {
viewModel.onStartOver()
onBack()
},
) {
val fatal = state.fatal
if (fatal != null) {
Message(icon = { Icon(Icons.Rounded.CloudOff, contentDescription = null) }, text = fatal)
Spacer(Modifier.height(16.dp))
Button(
onClick = viewModel::onStartOver,
modifier = Modifier.padding(horizontal = 16.dp),
) { Text(stringResource(R.string.add_account_start_over)) }
return@CollapsingScaffold
}
when (val step = state.step) {
is AddAccountStep.EnterServer -> ServerStep(step, state.quirk, viewModel)
is AddAccountStep.Working -> WorkingStep(step)
is AddAccountStep.EnterCredentials -> CredentialsStep(step, viewModel)
is AddAccountStep.WaitingForBrowser -> BrowserStep(step, viewModel)
is AddAccountStep.ChooseLists -> ListsStep(step, viewModel)
AddAccountStep.Done -> Unit
}
}
}
@Composable
private fun ServerStep(
step: AddAccountStep.EnterServer,
quirk: ServerQuirk?,
viewModel: AddAccountViewModel,
) {
Column(Modifier.padding(horizontal = 16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
OutlinedTextField(
value = step.input,
onValueChange = viewModel::onServerInputChanged,
label = { Text(stringResource(R.string.add_account_server_label)) },
placeholder = { Text(stringResource(R.string.add_account_server_hint)) },
isError = step.error != null,
supportingText = step.error?.let { { Text(it) } },
singleLine = true,
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Uri,
imeAction = ImeAction.Go,
),
modifier = Modifier.fillMaxWidth(),
)
// Named before the attempt, not after a 401 the user cannot act on.
quirk?.let { QuirkNote(it) }
Button(
onClick = viewModel::onServerSubmitted,
enabled = step.input.isNotBlank(),
modifier = Modifier.fillMaxWidth(),
) { Text(stringResource(R.string.add_account_continue)) }
}
}
@Composable
private fun CredentialsStep(step: AddAccountStep.EnterCredentials, viewModel: AddAccountViewModel) {
Column(Modifier.padding(horizontal = 16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
OutlinedTextField(
value = step.username,
onValueChange = viewModel::onUsernameChanged,
label = { Text(stringResource(R.string.add_account_username_label)) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = step.password,
onValueChange = viewModel::onPasswordChanged,
label = { Text(stringResource(R.string.add_account_password_label)) },
visualTransformation = PasswordVisualTransformation(),
isError = step.error != null,
supportingText = {
Text(step.error ?: stringResource(R.string.add_account_password_hint))
},
singleLine = true,
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Password,
imeAction = ImeAction.Go,
),
modifier = Modifier.fillMaxWidth(),
)
Button(
onClick = viewModel::onCredentialsSubmitted,
enabled = step.username.isNotBlank() && step.password.isNotEmpty(),
modifier = Modifier.fillMaxWidth(),
) { Text(stringResource(R.string.add_account_sign_in)) }
}
}
@Composable
private fun BrowserStep(step: AddAccountStep.WaitingForBrowser, viewModel: AddAccountViewModel) {
Column(Modifier.padding(horizontal = 16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
Message(
icon = {
if (step.error == null) {
CircularProgressIndicator(Modifier.size(24.dp))
} else {
Icon(Icons.Rounded.CloudOff, contentDescription = null)
}
},
title = stringResource(R.string.add_account_browser_title),
text = step.error ?: stringResource(R.string.add_account_browser_body),
)
step.hostMismatch?.let { mismatch ->
QuirkNote(
text = stringResource(
R.string.add_account_browser_host_mismatch,
mismatch.actual,
mismatch.expected,
),
)
}
TextButton(
onClick = viewModel::onBrowserCancelled,
modifier = Modifier.fillMaxWidth(),
) { Text(stringResource(R.string.add_account_browser_use_password)) }
}
}
@Composable
private fun WorkingStep(step: AddAccountStep.Working) {
Column(
Modifier.fillMaxWidth().padding(32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
CircularProgressIndicator()
Text(step.message, style = MaterialTheme.typography.bodyLarge)
}
}
@Composable
private fun ListsStep(step: AddAccountStep.ChooseLists, viewModel: AddAccountViewModel) {
Text(
stringResource(R.string.add_account_lists_title),
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
)
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,
modifier = Modifier.padding(horizontal = 16.dp),
trailing = {
Checkbox(checked = checked, onCheckedChange = null)
},
onClick = { viewModel.onListToggled(collection.url) },
)
}
Spacer(Modifier.height(16.dp))
Button(
onClick = viewModel::onSave,
enabled = step.selected.isNotEmpty(),
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp),
) {
Text(
if (step.selected.isEmpty()) {
stringResource(R.string.add_account_no_lists_selected)
} else {
stringResource(R.string.add_account_save)
},
)
}
}
@Composable
private fun QuirkNote(quirk: ServerQuirk) {
val text = when (quirk) {
ServerQuirk.FASTMAIL_APP_PASSWORD ->
"Fastmail needs an app password, and CalDAV is not available on the Basic plan."
ServerQuirk.ICLOUD_APP_SPECIFIC_PASSWORD ->
"iCloud needs an app-specific password, created at appleid.apple.com with two-factor on. " +
"Tasks stored there will not appear in Reminders."
ServerQuirk.GOOGLE_UNSUPPORTED ->
"Google Calendar does not support tasks over CalDAV."
ServerQuirk.NEXTCLOUD_BRUTE_FORCE_PROTECTED -> return
}
QuirkNote(text)
}
@Composable
private fun QuirkNote(text: String) {
GroupedSurface(
position = Position.Alone,
color = MaterialTheme.colorScheme.tertiaryContainer,
gapBelow = false,
) {
Text(
text,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onTertiaryContainer,
modifier = Modifier.padding(16.dp),
)
}
}
@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,423 @@
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.sync.AccountCreator
import de.jeanlucmakiola.agendula.data.sync.AccountRepository
import de.jeanlucmakiola.agendula.data.sync.CalDavGateway
import de.jeanlucmakiola.caldav.CalDavDiscovery
import de.jeanlucmakiola.caldav.NextcloudLoginFlow
import de.jeanlucmakiola.caldav.ServerQuirk
import de.jeanlucmakiola.caldav.ServiceDiscovery
import de.jeanlucmakiola.caldav.TaskCollection
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import okhttp3.HttpUrl
import javax.inject.Inject
/** Where the user is in adding an account. */
sealed interface AddAccountStep {
/** Type an address. The whole flow starts from one field. */
data class EnterServer(val input: String = "", val error: String? = null) : AddAccountStep
data class Working(val message: String) : AddAccountStep
/** The server wants credentials and is not a Nextcloud we can hand to a browser. */
data class EnterCredentials(
val username: String = "",
val password: String = "",
val error: String? = null,
) : AddAccountStep
/**
* The browser has the flow. We poll until the user approves, and offer an
* explicit way out — Custom Tabs return **no result** when dismissed, and
* Nextcloud's flow ends on a "you can close this window" page that never
* comes back to the app.
*/
data class WaitingForBrowser(
val hostMismatch: NextcloudLoginFlow.HostMismatch? = null,
val error: String? = null,
) : AddAccountStep
data class ChooseLists(
val collections: List<TaskCollection>,
val selected: Set<HttpUrl>,
) : AddAccountStep
data object Done : AddAccountStep
}
data class AddAccountUiState(
val step: AddAccountStep = AddAccountStep.EnterServer(),
/**
* A warning the user should read before going further — the three providers
* whose real failure is not "wrong password", and a server that sent the
* login flow to a different host than the one typed.
*/
val quirk: ServerQuirk? = null,
/** Set when the flow cannot continue at all; the UI offers only "start over". */
val fatal: String? = null,
/** Non-null once the browser flow has a URL to open. */
val openInBrowser: HttpUrl? = null,
)
@HiltViewModel
class AddAccountViewModel @Inject constructor(
private val repository: AccountCreator,
private val gateway: CalDavGateway,
) : ViewModel() {
private val _state = MutableStateFlow(AddAccountUiState())
val state: StateFlow<AddAccountUiState> = _state.asStateFlow()
private var typedInput: String = ""
private var serverRoot: HttpUrl? = null
private var username: String = ""
private var appPassword: String = ""
private var found: CalDavDiscovery.Outcome.Found? = null
private var pollJob: Job? = null
/** Which hosts asked for credentials, for the cross-domain diagnostic. */
private var hostsNeedingAuth: List<String> = emptyList()
fun onServerInputChanged(value: String) = _state.update {
it.copy(step = AddAccountStep.EnterServer(value), quirk = ServerQuirk.forInput(value))
}
fun onUsernameChanged(value: String) = updateCredentials { it.copy(username = value) }
fun onPasswordChanged(value: String) = updateCredentials { it.copy(password = value) }
/** Step 1 → discovery, unauthenticated. */
fun onServerSubmitted() {
val input = (_state.value.step as? AddAccountStep.EnterServer)?.input?.trim().orEmpty()
if (input.isEmpty()) return
typedInput = input
val quirk = ServerQuirk.forInput(input)
if (quirk?.isFatal == true) {
// Google supports neither VTODO nor MKCALENDAR. Refusing with an
// explanation beats a 401 the user cannot act on.
_state.update {
it.copy(
step = AddAccountStep.EnterServer(input = input),
fatal = FATAL_GOOGLE,
quirk = quirk,
)
}
return
}
serverRoot = ServiceDiscovery.serverRootFor(input)
working("Looking for a CalDAV server")
viewModelScope.launch {
when (val outcome = gateway.discover(typedInput)) {
is CalDavDiscovery.Outcome.Found -> onDiscovered(outcome)
is CalDavDiscovery.Outcome.NeedsAuthentication -> {
hostsNeedingAuth = outcome.hosts
offerSignIn()
}
// No credentials were sent, so this is not a rejection — it is a
// server that answers RFC 5397 <D:unauthenticated/> on a 200
// instead of a 401. Sending the user back to the address field
// would make it permanently unreachable.
CalDavDiscovery.Outcome.Unauthenticated -> offerSignIn()
is CalDavDiscovery.Outcome.NotCalDav -> backToServer(outcome.reason)
is CalDavDiscovery.Outcome.Failed -> backToServer(outcome.reason)
}
}
}
/** Step 2a → re-run discovery with the password the user typed. */
fun onCredentialsSubmitted() {
val step = _state.value.step as? AddAccountStep.EnterCredentials ?: return
if (step.username.isBlank() || step.password.isEmpty()) return
username = step.username.trim()
appPassword = step.password
working("Signing in")
viewModelScope.launch {
val credentials = serverRoot?.let {
CalDavGateway.Credentials(username, appPassword, it)
}
when (val outcome = gateway.discover(typedInput, credentials)) {
is CalDavDiscovery.Outcome.Found -> onDiscovered(outcome)
// A second rejection is the point at which naming the provider's
// own rule is worth more than repeating "wrong password".
is CalDavDiscovery.Outcome.NeedsAuthentication,
CalDavDiscovery.Outcome.Unauthenticated,
-> _state.update {
it.copy(
step = AddAccountStep.EnterCredentials(
username = username,
password = "",
error = quirkHint() ?: crossDomainHint()
?: "That username or password was not accepted.",
),
)
}
is CalDavDiscovery.Outcome.NotCalDav -> backToServer(outcome.reason)
is CalDavDiscovery.Outcome.Failed -> backToServer(outcome.reason)
}
}
}
/** Step 2b → the browser flow, if this looks like a Nextcloud. */
private suspend fun offerSignIn() {
val root = serverRoot ?: return backToServer("Could not work out the server address.")
val flow = gateway.startLoginFlow(root)
if (flow == null) {
// Not a Nextcloud, or its login flow is unavailable. Ask for a
// username and password instead — that is the generic CalDAV path.
_state.update {
it.copy(step = AddAccountStep.EnterCredentials(error = quirkHint()))
}
return
}
_state.update {
it.copy(
step = AddAccountStep.WaitingForBrowser(hostMismatch = flow.hostMismatch),
openInBrowser = flow.loginUrl,
)
}
startPolling(flow)
}
private fun startPolling(flow: NextcloudLoginFlow.Flow) {
pollJob?.cancel()
pollJob = viewModelScope.launch {
// ⚠️ Bounded here, not just by the server's answer. `poll` reports
// Expired past the flow's deadline, but an unbounded `while (true)`
// makes this loop's termination somebody else's responsibility — and
// a gateway that keeps saying "pending" would have it poll forever.
repeat(MAX_POLL_ATTEMPTS) {
delay(POLL_INTERVAL_MILLIS)
when (val result = gateway.pollLoginFlow(flow)) {
is NextcloudLoginFlow.PollResult.Approved -> {
username = result.credentials.loginName
appPassword = result.credentials.appPassword
serverRoot = result.credentials.server
working("Reading your task lists")
val outcome = gateway.discover(
// The server the credentials were *issued by*, not the
// host the user typed — the two differ in exactly the
// host-mismatch case this flow already models, and
// getting it wrong burns the one-shot app password.
target = result.credentials.server.toString(),
credentials = CalDavGateway.Credentials(
username,
appPassword,
result.credentials.server,
),
)
if (outcome is CalDavDiscovery.Outcome.Found) {
onDiscovered(outcome)
} else {
backToServer("Signed in, but no task lists could be read.")
}
return@launch
}
NextcloudLoginFlow.PollResult.Pending -> Unit
is NextcloudLoginFlow.PollResult.Expired -> {
browserFailed(result.reason)
return@launch
}
is NextcloudLoginFlow.PollResult.Failed -> {
browserFailed(result.reason)
return@launch
}
}
}
browserFailed("The approval window closed before the server answered.")
}
}
/** The user says they finished in the browser but nothing arrived. */
fun onBrowserCancelled() {
pollJob?.cancel()
_state.update {
it.copy(step = AddAccountStep.EnterCredentials(error = null), openInBrowser = null)
}
}
fun onBrowserLaunched() = _state.update { it.copy(openInBrowser = null) }
fun onListToggled(url: HttpUrl) {
val step = _state.value.step as? AddAccountStep.ChooseLists ?: return
val selected = if (url in step.selected) step.selected - url else step.selected + url
_state.update { it.copy(step = step.copy(selected = selected)) }
}
fun onSave() {
val step = _state.value.step as? AddAccountStep.ChooseLists ?: return
val discovered = found ?: return
val chosen = step.collections.filter { it.url in step.selected }.toSet()
if (username.isBlank() || appPassword.isEmpty()) {
// A server that needed no credentials at all would otherwise be saved
// with an empty username and password, and fail every later sync.
_state.update { it.copy(step = AddAccountStep.EnterCredentials()) }
return
}
working("Adding the account")
viewModelScope.launch {
val outcome = runCatching {
repository.create(
displayName = accountName(),
username = username,
appPassword = appPassword,
found = discovered,
selected = chosen,
)
}.getOrElse {
// A constraint violation or an IO failure in DataStore must not
// take the app down and leave the spinner up forever.
AccountRepository.Outcome.CredentialFailed(
it.message ?: "the account could not be saved",
)
}
when (outcome) {
is AccountRepository.Outcome.Created ->
_state.update { it.copy(step = AddAccountStep.Done) }
AccountRepository.Outcome.AlreadyExists ->
fatal("That account is already set up.")
is AccountRepository.Outcome.CredentialFailed -> fatal(outcome.reason)
}
}
}
/**
* Full reset, including the credentials.
*
* Called on "start over", on leaving the screen, and after a successful add.
* The ViewModel is scoped to the Settings back-stack entry and the flow is an
* `AnimatedVisibility` section inside it, so it outlives the composable:
* without this, re-opening "Add account" would find `step = Done` and bounce
* straight back out, and a second account would be created with the *first*
* account's username and app password.
*/
fun onStartOver() {
pollJob?.cancel()
pollJob = null
found = null
typedInput = ""
serverRoot = null
username = ""
appPassword = ""
hostsNeedingAuth = emptyList()
_state.value = AddAccountUiState()
}
override fun onCleared() {
pollJob?.cancel()
}
// ------------------------------------------------------------- internals
private fun onDiscovered(outcome: CalDavDiscovery.Outcome.Found) {
found = outcome
if (outcome.collections.isEmpty()) {
fatal("Signed in, but this account has no task lists that Agendula can use.")
return
}
_state.update {
it.copy(
step = AddAccountStep.ChooseLists(
collections = outcome.collections,
// Everything writable, pre-ticked: the common case is "all of
// them", and a read-only share is more often noise than not.
selected = outcome.collections.filterNot { c -> c.readOnly }
.map { c -> c.url }
.toSet(),
),
openInBrowser = null,
)
}
}
private fun working(message: String) =
_state.update { it.copy(step = AddAccountStep.Working(message), fatal = null) }
/**
* A dead end. The step goes back to the address underneath, so the spinner is
* actually gone rather than merely hidden behind the message — the screen
* happens to render `fatal` first, and relying on that leaves the state
* lying about what it is doing.
*/
private fun fatal(reason: String) = _state.update {
it.copy(step = AddAccountStep.EnterServer(input = typedInput), fatal = reason)
}
private fun backToServer(reason: String) = _state.update {
it.copy(step = AddAccountStep.EnterServer(input = typedInput, error = reason))
}
private fun browserFailed(reason: String) = _state.update {
it.copy(step = AddAccountStep.WaitingForBrowser(error = reason), openInBrowser = null)
}
private fun updateCredentials(transform: (AddAccountStep.EnterCredentials) -> AddAccountStep.EnterCredentials) {
val step = _state.value.step as? AddAccountStep.EnterCredentials ?: return
_state.update { it.copy(step = transform(step)) }
}
/** The provider-specific reason a correct-looking password gets rejected. */
private fun quirkHint(): String? = when (ServerQuirk.forInput(typedInput)) {
ServerQuirk.FASTMAIL_APP_PASSWORD ->
"Fastmail needs an app password, not your account password — and CalDAV is not on the Basic plan."
ServerQuirk.ICLOUD_APP_SPECIFIC_PASSWORD ->
"iCloud needs an app-specific password, which you create at appleid.apple.com with two-factor on."
else -> null
}
/**
* A home set on a different registrable domain than the account's own is
* legal (RFC 4791 §6.2.1) but unreachable for us: the credential is scoped to
* one domain. Better to name it than to leave "wrong password" standing.
*/
private fun crossDomainHint(): String? {
val root = serverRoot?.host ?: return null
val rootDomain = root.substringAfter('.', root)
val outside = hostsNeedingAuth.filterNot { it.endsWith(rootDomain) }
return outside.firstOrNull()?.let {
"This server keeps some task lists on $it, which Agendula cannot sign in to yet."
}
}
private fun accountName(): String =
username.takeIf { it.isNotBlank() }?.let { "$it@${serverRoot?.host.orEmpty()}" }
?: typedInput
private companion object {
const val POLL_INTERVAL_MILLIS = 2_000L
/** The server-side lifetime is 1200s; this covers it and then stops. */
const val MAX_POLL_ATTEMPTS = 600
const val FATAL_GOOGLE =
"Google Calendar does not support tasks over CalDAV — its own documentation says so — " +
"so Agendula cannot sync with it."
}
}
@@ -49,6 +49,7 @@ import androidx.compose.material.icons.rounded.AccountTree
import androidx.compose.material.icons.rounded.Circle import androidx.compose.material.icons.rounded.Circle
import androidx.compose.material.icons.rounded.Flag import androidx.compose.material.icons.rounded.Flag
import androidx.compose.material.icons.rounded.Percent import androidx.compose.material.icons.rounded.Percent
import androidx.compose.material.icons.rounded.CloudSync
import androidx.compose.material.icons.rounded.Storage import androidx.compose.material.icons.rounded.Storage
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
@@ -95,6 +96,9 @@ import de.jeanlucmakiola.floret.identity.expandEnter
import de.jeanlucmakiola.floret.reminders.ReminderOverride import de.jeanlucmakiola.floret.reminders.ReminderOverride
import de.jeanlucmakiola.floret.reminders.reminderOverrideFor import de.jeanlucmakiola.floret.reminders.reminderOverrideFor
import de.jeanlucmakiola.agendula.ui.common.OnResume import de.jeanlucmakiola.agendula.ui.common.OnResume
import de.jeanlucmakiola.agendula.ui.accounts.AccountsScreen
import de.jeanlucmakiola.agendula.ui.accounts.AccountsViewModel
import de.jeanlucmakiola.agendula.ui.accounts.AddAccountScreen
import de.jeanlucmakiola.agendula.ui.common.reminderLeadTimeLabel import de.jeanlucmakiola.agendula.ui.common.reminderLeadTimeLabel
/** The settings sub-screens reached from the hub's category rows. */ /** The settings sub-screens reached from the hub's category rows. */
@@ -104,11 +108,17 @@ private enum class SettingsSection {
Reminders, Reminders,
Storage, Storage,
Export, Export,
Accounts,
AddAccount,
; ;
/** Where back goes: Export is opened from Storage, not from the hub. */ /** Where back goes: Export is opened from Storage, AddAccount from Accounts. */
val parent: SettingsSection? val parent: SettingsSection?
get() = if (this == Export) Storage else null get() = when (this) {
Export -> Storage
AddAccount -> Accounts
else -> null
}
} }
/** /**
@@ -132,6 +142,8 @@ fun SettingsScreen(
) { ) {
val state by viewModel.state.collectAsStateWithLifecycle() val state by viewModel.state.collectAsStateWithLifecycle()
var section by rememberSaveable { mutableStateOf<SettingsSection?>(null) } var section by rememberSaveable { mutableStateOf<SettingsSection?>(null) }
// Hoisted so the add flow can refresh the list it returns to.
val accountsViewModel: AccountsViewModel = hiltViewModel()
// Inside a sub-screen, system back (button or gesture) returns to the hub // Inside a sub-screen, system back (button or gesture) returns to the hub
// rather than popping the whole Settings destination to the lists overview. // rather than popping the whole Settings destination to the lists overview.
@@ -166,6 +178,29 @@ fun SettingsScreen(
SlideInSection(visible = section == SettingsSection.Export) { SlideInSection(visible = section == SettingsSection.Export) {
ExportScreen(onBack = { section = SettingsSection.Storage }) ExportScreen(onBack = { section = SettingsSection.Storage })
} }
// 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 = { section = SettingsSection.AddAccount },
onBack = { section = null },
viewModel = accountsViewModel,
)
}
SlideInSection(visible = section == SettingsSection.AddAccount) {
AddAccountScreen(
onDone = {
// The list stays composed underneath, so nothing re-runs its
// init and OnResume never fires on a section change — without
// this the user returns to the empty state they just left.
accountsViewModel.refresh()
section = SettingsSection.Accounts
},
onBack = { section = SettingsSection.Accounts },
)
}
} }
} }
@@ -242,6 +277,13 @@ private fun SettingsHub(
leading = { CategoryIcon(Icons.Rounded.Storage, ChipAccent.Neutral) }, leading = { CategoryIcon(Icons.Rounded.Storage, ChipAccent.Neutral) },
onClick = { onOpenSection(SettingsSection.Storage) }, onClick = { onOpenSection(SettingsSection.Storage) },
) )
GroupedRow(
title = stringResource(R.string.settings_section_accounts),
summary = stringResource(R.string.settings_accounts_subtitle),
position = Position.Middle,
leading = { CategoryIcon(Icons.Rounded.CloudSync, ChipAccent.Tertiary) },
onClick = { onOpenSection(SettingsSection.Accounts) },
)
LanguageRow(position = Position.Middle) LanguageRow(position = Position.Middle)
ReportProblemRow(position = Position.Bottom) ReportProblemRow(position = Position.Bottom)
+36
View File
@@ -294,4 +294,40 @@
<item quantity="one">%1$d week before</item> <item quantity="one">%1$d week before</item>
<item quantity="other">%1$d weeks before</item> <item quantity="other">%1$d weeks before</item>
</plurals> </plurals>
<!-- CalDAV accounts -->
<string name="settings_section_accounts">Accounts</string>
<string name="settings_accounts_subtitle">Sync your tasks with a CalDAV server</string>
<string name="accounts_empty_title">No accounts yet</string>
<string name="accounts_empty_body">Add a CalDAV account and Agendula keeps your task lists in step with it — Nextcloud, Radicale, Baïkal and anything else that speaks the protocol.</string>
<string name="accounts_add">Add an account</string>
<string name="accounts_remove">Remove account</string>
<string name="accounts_remove_confirm_title">Remove %1$s?</string>
<string name="accounts_remove_confirm_body">Its task lists stay on this device and stop syncing. Nothing is deleted from the server.</string>
<string name="accounts_remove_confirm_action">Remove</string>
<string name="accounts_never_synced">Never synced</string>
<string name="accounts_sync_failed">Last sync didn\u2019t finish</string>
<string name="add_account_title">Add an account</string>
<string name="add_account_server_label">Email address or server address</string>
<string name="add_account_server_hint">you@example.com, or https://cloud.example.com</string>
<string name="add_account_continue">Continue</string>
<string name="add_account_start_over">Start over</string>
<string name="add_account_username_label">User name</string>
<string name="add_account_password_label">Password</string>
<string name="add_account_password_hint">Use an app password if your server offers one — it can be revoked without changing your account password.</string>
<string name="add_account_sign_in">Sign in</string>
<string name="add_account_browser_title">Waiting for your browser</string>
<string name="add_account_browser_body">Approve Agendula in the browser, then come back here. Your password is never sent to this app — the server issues a separate app password you can revoke at any time.</string>
<string name="add_account_browser_reopen">Open the browser again</string>
<string name="add_account_browser_use_password">Use a password instead</string>
<string name="add_account_browser_host_mismatch">The server sent us to %1$s, but you typed %2$s. That usually means its overwrite.cli.url setting is wrong.</string>
<string name="add_account_lists_title">Which lists should sync?</string>
<string name="add_account_lists_read_only">Read-only</string>
<string name="add_account_lists_shared">Shared with you</string>
<string name="add_account_save">Add account</string>
<string name="add_account_no_lists_selected">Pick at least one list</string>
</resources> </resources>
@@ -0,0 +1,361 @@
package de.jeanlucmakiola.agendula.ui.accounts
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.agendula.data.sync.AccountCreator
import de.jeanlucmakiola.agendula.data.sync.AccountRepository
import de.jeanlucmakiola.agendula.data.sync.CalDavGateway
import de.jeanlucmakiola.caldav.CalDavDiscovery
import de.jeanlucmakiola.caldav.NextcloudLoginFlow
import de.jeanlucmakiola.caldav.TaskCollection
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrl
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test
/**
* The sign-in state machine.
*
* Worth testing closely: it decides where five discovery outcomes lead, when a
* one-shot app password is spent, and which host a credential is scoped to —
* all of which fail quietly, and none of which needs a server to exercise.
*/
@OptIn(ExperimentalCoroutinesApi::class)
class AddAccountViewModelTest {
private val dispatcher = StandardTestDispatcher()
private val gateway = FakeGateway()
private val creator = FakeCreator()
@BeforeEach fun setUp() = Dispatchers.setMain(dispatcher)
@AfterEach fun tearDown() = Dispatchers.resetMain()
private fun viewModel() = AddAccountViewModel(creator, gateway)
@Nested
inner class TheAddressStep {
@Test
fun `Google is refused before any request is made`() = runTest(dispatcher) {
val vm = viewModel()
vm.onServerInputChanged("me@gmail.com")
vm.onServerSubmitted()
advanceUntilIdle()
// It supports neither VTODO nor MKCALENDAR — its own docs say so — so
// a 401 the user cannot act on is the wrong answer.
assertThat(vm.state.value.fatal).contains("does not support tasks")
assertThat(gateway.discoveries).isEmpty()
}
@Test
fun `a provider quirk is named while the user is still typing`() = runTest(dispatcher) {
val vm = viewModel()
vm.onServerInputChanged("me@fastmail.com")
assertThat(vm.state.value.quirk).isNotNull()
}
@Test
fun `an unauthenticated 200 offers sign-in rather than bouncing back`() =
runTest(dispatcher) {
// No credentials were sent, so RFC 5397's <D:unauthenticated/> is
// not a rejection. Sending the user back to the address field
// would make such a server permanently unreachable.
gateway.discoveryOutcomes += CalDavDiscovery.Outcome.Unauthenticated
gateway.loginFlow = null
val vm = viewModel()
vm.onServerInputChanged("https://cloud.example.com/")
vm.onServerSubmitted()
advanceUntilIdle()
assertThat(vm.state.value.step)
.isInstanceOf(AddAccountStep.EnterCredentials::class.java)
}
@Test
fun `a server that is not CalDAV says so on the address step`() = runTest(dispatcher) {
gateway.discoveryOutcomes += CalDavDiscovery.Outcome.NotCalDav("no calendar-access")
val vm = viewModel()
vm.onServerInputChanged("https://example.com/")
vm.onServerSubmitted()
advanceUntilIdle()
val step = vm.state.value.step as AddAccountStep.EnterServer
assertThat(step.error).contains("calendar-access")
// The input survives, so the user can correct it rather than retype it.
assertThat(step.input).isEqualTo("https://example.com/")
}
}
@Nested
inner class TheBrowserStep {
@Test
fun `discovery after approval targets the server that issued the credentials`() =
runTest(dispatcher) {
// The host-mismatch case: the user typed one host, the server
// reported another. Probing the typed one with a credential scoped
// to the reported one 401s and burns the one-shot app password.
gateway.discoveryOutcomes += CalDavDiscovery.Outcome.NeedsAuthentication(
listOf("cloud.example.com"),
)
gateway.loginFlow = flow()
gateway.pollResults += NextcloudLoginFlow.PollResult.Approved(
NextcloudLoginFlow.Credentials(
server = "https://dav.example.com/".toHttpUrl(),
loginName = "me",
appPassword = "app-pw",
),
)
gateway.discoveryOutcomes += found(collection("Tasks"))
val vm = viewModel()
vm.onServerInputChanged("https://cloud.example.com/")
vm.onServerSubmitted()
advanceUntilIdle()
assertThat(gateway.discoveries.last().target).isEqualTo("https://dav.example.com/")
assertThat(gateway.discoveries.last().credentials?.origin.toString())
.isEqualTo("https://dav.example.com/")
}
@Test
fun `the login URL is handed to the browser exactly once`() = runTest(dispatcher) {
gateway.discoveryOutcomes += CalDavDiscovery.Outcome.NeedsAuthentication(emptyList())
gateway.loginFlow = flow()
val vm = viewModel()
vm.onServerInputChanged("https://cloud.example.com/")
vm.onServerSubmitted()
// runCurrent, not advanceUntilIdle: the poll loop's first act is a
// delay, so this settles discovery without consuming the flow.
runCurrent()
assertThat(vm.state.value.openInBrowser).isNotNull()
vm.onBrowserLaunched()
assertThat(vm.state.value.openInBrowser).isNull()
vm.onStartOver()
}
@Test
fun `polling stops on its own rather than running forever`() = runTest(dispatcher) {
gateway.discoveryOutcomes += CalDavDiscovery.Outcome.NeedsAuthentication(emptyList())
gateway.loginFlow = flow()
// The fake never approves, so only the loop's own bound ends it.
val vm = viewModel()
vm.onServerInputChanged("https://cloud.example.com/")
vm.onServerSubmitted()
advanceUntilIdle()
val step = vm.state.value.step as AddAccountStep.WaitingForBrowser
assertThat(step.error).isNotNull()
}
@Test
fun `a server with no login flow falls back to a password`() = runTest(dispatcher) {
gateway.discoveryOutcomes += CalDavDiscovery.Outcome.NeedsAuthentication(emptyList())
gateway.loginFlow = null
val vm = viewModel()
vm.onServerInputChanged("https://baikal.example.com/")
vm.onServerSubmitted()
advanceUntilIdle()
assertThat(vm.state.value.step)
.isInstanceOf(AddAccountStep.EnterCredentials::class.java)
}
}
@Nested
inner class ChoosingLists {
@Test
fun `writable lists are pre-ticked and read-only ones are not`() = runTest(dispatcher) {
gateway.discoveryOutcomes += found(
collection("Mine"),
collection("Someone else's", readOnly = true),
)
val vm = viewModel()
vm.onServerInputChanged("https://cloud.example.com/")
vm.onServerSubmitted()
advanceUntilIdle()
val step = vm.state.value.step as AddAccountStep.ChooseLists
assertThat(step.selected).hasSize(1)
assertThat(step.selected.single().toString()).contains("Mine")
}
@Test
fun `an account with no usable lists is a dead end worth explaining`() =
runTest(dispatcher) {
gateway.discoveryOutcomes += found()
val vm = viewModel()
vm.onServerInputChanged("https://cloud.example.com/")
vm.onServerSubmitted()
advanceUntilIdle()
assertThat(vm.state.value.fatal).contains("no task lists")
}
}
@Nested
inner class Finishing {
@Test
fun `starting over clears the previous account's credentials`() = runTest(dispatcher) {
// Without this, a second account is created with the first account's
// username and app password.
gateway.discoveryOutcomes += CalDavDiscovery.Outcome.NeedsAuthentication(emptyList())
gateway.loginFlow = null
val vm = viewModel()
vm.onServerInputChanged("https://cloud.example.com/")
vm.onServerSubmitted()
advanceUntilIdle()
vm.onUsernameChanged("me")
vm.onPasswordChanged("first-pw")
vm.onStartOver()
assertThat(vm.state.value).isEqualTo(AddAccountUiState())
// And a save attempt now has nothing to save, rather than reusing it.
gateway.discoveryOutcomes += found(collection("Tasks"))
vm.onServerInputChanged("https://other.example.com/")
vm.onServerSubmitted()
advanceUntilIdle()
vm.onSave()
advanceUntilIdle()
assertThat(creator.created).isEmpty()
assertThat(vm.state.value.step)
.isInstanceOf(AddAccountStep.EnterCredentials::class.java)
}
@Test
fun `a failure inside create leaves a readable message, not a spinner`() =
runTest(dispatcher) {
creator.thrown = IllegalStateException("UNIQUE constraint failed")
val vm = signedIn()
vm.onSave()
advanceUntilIdle()
assertThat(vm.state.value.fatal).contains("UNIQUE constraint failed")
assertThat(vm.state.value.step).isNotInstanceOf(AddAccountStep.Working::class.java)
}
@Test
fun `a completed add reports the account it created`() = runTest(dispatcher) {
val vm = signedIn()
vm.onSave()
advanceUntilIdle()
assertThat(creator.created).containsExactly("me@cloud.example.com")
assertThat(vm.state.value.step).isEqualTo(AddAccountStep.Done)
}
}
/**
* A ViewModel driven the way the screen drives it, up to the list step:
* address → credentials → discovery → lists. Credentials are captured on
* submit, so going straight from the address to a `Found` leaves the
* username and password empty.
*/
private fun kotlinx.coroutines.test.TestScope.signedIn(): AddAccountViewModel {
gateway.discoveryOutcomes += CalDavDiscovery.Outcome.NeedsAuthentication(emptyList())
gateway.loginFlow = null
val vm = viewModel()
vm.onServerInputChanged("https://cloud.example.com/")
vm.onServerSubmitted()
advanceUntilIdle()
gateway.discoveryOutcomes += found(collection("Tasks"))
vm.onUsernameChanged("me")
vm.onPasswordChanged("pw")
vm.onCredentialsSubmitted()
advanceUntilIdle()
return vm
}
// ------------------------------------------------------------- fixtures
private fun collection(name: String, readOnly: Boolean = false) = TaskCollection(
url = "https://cloud.example.com/dav/$name/".toHttpUrl(),
displayName = name,
color = null,
readOnly = readOnly,
isShared = false,
supportsSyncCollection = true,
maxResourceSize = null,
)
private fun found(vararg collections: TaskCollection) = CalDavDiscovery.Outcome.Found(
principal = "https://cloud.example.com/principals/me/".toHttpUrl(),
collections = collections.toList(),
homeSets = listOf("https://cloud.example.com/dav/".toHttpUrl()),
movedTo = null,
crossHostHomeSets = emptyList(),
)
private fun flow() = NextcloudLoginFlow.Flow(
loginUrl = "https://cloud.example.com/login/flow".toHttpUrl(),
pollEndpoint = "https://cloud.example.com/login/v2/poll".toHttpUrl(),
pollToken = "token",
deadlineEpochSeconds = Long.MAX_VALUE,
)
private class FakeGateway : CalDavGateway {
data class Call(val target: String, val credentials: CalDavGateway.Credentials?)
val discoveries = mutableListOf<Call>()
val discoveryOutcomes = ArrayDeque<CalDavDiscovery.Outcome>()
val pollResults = ArrayDeque<NextcloudLoginFlow.PollResult>()
var loginFlow: NextcloudLoginFlow.Flow? = null
override suspend fun discover(
target: String,
credentials: CalDavGateway.Credentials?,
): CalDavDiscovery.Outcome {
discoveries += Call(target, credentials)
return discoveryOutcomes.removeFirstOrNull()
?: CalDavDiscovery.Outcome.Failed("no outcome queued")
}
override suspend fun startLoginFlow(server: HttpUrl) = loginFlow
override suspend fun pollLoginFlow(flow: NextcloudLoginFlow.Flow) =
pollResults.removeFirstOrNull() ?: NextcloudLoginFlow.PollResult.Pending
}
private class FakeCreator : AccountCreator {
val created = mutableListOf<String>()
var thrown: Throwable? = null
override suspend fun create(
displayName: String,
username: String,
appPassword: String,
found: CalDavDiscovery.Outcome.Found,
selected: Set<TaskCollection>,
): AccountRepository.Outcome {
thrown?.let { throw it }
created += displayName
return AccountRepository.Outcome.Created(created.size.toLong())
}
}
}
@@ -60,6 +60,8 @@ class CalDavDiscovery(
data class Found( data class Found(
val principal: HttpUrl, val principal: HttpUrl,
val collections: List<TaskCollection>, val collections: List<TaskCollection>,
/** Every `calendar-home-set` href, in the order the principal listed them. */
val homeSets: List<HttpUrl>,
/** Set when a 301/308 moved us; the caller must persist it. */ /** Set when a 301/308 moved us; the caller must persist it. */
val movedTo: HttpUrl?, val movedTo: HttpUrl?,
/** Home sets on a different host than the principal. Normative, but worth surfacing. */ /** Home sets on a different host than the principal. Normative, but worth surfacing. */
@@ -244,6 +246,7 @@ class CalDavDiscovery(
return Outcome.Found( return Outcome.Found(
principal = principal, principal = principal,
collections = collections.values.toList(), collections = collections.values.toList(),
homeSets = homeSets.distinct(),
movedTo = movedTo, movedTo = movedTo,
crossHostHomeSets = crossHost, crossHostHomeSets = crossHost,
failedHomeSets = failures, failedHomeSets = failures,
@@ -0,0 +1,89 @@
package de.jeanlucmakiola.caldav
import at.bitfire.dav4jvm.BasicDigestAuthHandler
import at.bitfire.dav4jvm.UrlUtils
import okhttp3.HttpUrl
import okhttp3.OkHttpClient
import java.util.concurrent.TimeUnit
/**
* The HTTP clients the CalDAV layer talks through.
*
* ⚠️ `followRedirects(false)` is mandatory, not a preference: `DavResource`
* requires it and asserts on it. Redirects are followed by hand so a
* HTTPS→HTTP downgrade can be refused and a permanent move can be reported to
* the caller — see `dav/PROVENANCE.md` change 3.
*/
object CalDavHttp {
/**
* One shared base client, so every derived client reuses its connection pool
* and dispatcher threads. Building a fresh `OkHttpClient` per probe gives
* each its own pool — every rung of the RFC 6764 ladder reopens TLS, and the
* abandoned clients' idle threads live until GC.
*/
private val shared: OkHttpClient by lazy {
OkHttpClient.Builder()
.followRedirects(false)
// A homelab server on the end of a slow link is normal; a hung socket
// is not. Bounded so a killed worker is the exception, not the rule.
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(120, TimeUnit.SECONDS)
.writeTimeout(120, TimeUnit.SECONDS)
.build()
}
/** Discovery before we have credentials, and the Nextcloud login flow. */
fun anonymous(userAgent: String): OkHttpClient = base(userAgent).build()
/**
* Authenticated against [origin]'s registrable domain.
*
* Uses the vendored [BasicDigestAuthHandler] rather than a hand-rolled
* interceptor, and it is worth saying why, because a preemptive-Basic
* interceptor is the obvious thing to write and this project wrote one first:
*
* - **It does Digest.** Baïkal defaults to `dav_auth_type = Digest` and OkHttp
* has no Digest support of its own (square/okhttp#205, open for years).
* Baïkal is squarely in the self-hosting audience.
* - **It already sends Basic preemptively over HTTPS**, and only over HTTPS,
* so the extra round trip on every request of a PROPFIND-heavy sync is
* avoided without a second implementation.
* - **It restricts by registrable domain**, which is what a cross-host home
* set needs: iCloud puts the principal on `caldav.icloud.com` and the home
* set on `pNN-caldav.icloud.com`, and an exact-host allowlist refuses the
* second one.
* - It caches which scheme worked, so the challenge is paid once.
*/
fun authenticated(
userAgent: String,
username: String,
password: String,
origin: HttpUrl,
): OkHttpClient {
val handler = BasicDigestAuthHandler(
// ⚠️ The **registrable** domain, not the host. The handler compares
// its `domain` against UrlUtils.hostToDomain(request host), which
// keeps only the last two labels — so passing "cloud.example.com"
// compares it to "example.com", never matches, and the credential is
// withheld from every request. That is every self-hosted Nextcloud.
domain = UrlUtils.hostToDomain(origin.host),
username = username,
password = password,
// Never preemptively over cleartext. The handler already gates its
// own preemptive path on isHttps; stating it is cheap insurance.
insecurePreemptive = false,
)
return base(userAgent)
.authenticator(handler)
.addNetworkInterceptor(handler)
.build()
}
private fun base(userAgent: String) = shared.newBuilder()
.addInterceptor { chain ->
chain.proceed(
chain.request().newBuilder().header("User-Agent", userAgent).build(),
)
}
}
@@ -1,57 +0,0 @@
package de.jeanlucmakiola.caldav
import okhttp3.Credentials
import okhttp3.HttpUrl
import okhttp3.Interceptor
import okhttp3.Response
/**
* Sends Basic credentials up front, rather than waiting to be challenged.
*
* OkHttp's `Authenticator` is **reactive only**: it fires after a 401, which
* costs an extra round trip on every request of a PROPFIND-heavy sync — and it
* never fires at all on servers that answer 403 or 404 without a challenge.
*
* Two guards, and neither is optional. The credential goes out **only over
* HTTPS**, and **only to the account's own origin** — a home set may legally live
* on another host, and OkHttp deliberately strips `Authorization` across a
* redirect, so re-attaching it is something we must do knowingly, per host, after
* validating the target. Never blanket.
*/
class PreemptiveBasicInterceptor(
private val username: String,
private val password: String,
/** Hosts this credential may be sent to. */
private val allowedHosts: Set<String>,
) : Interceptor {
constructor(username: String, password: String, origin: HttpUrl) :
this(username, password, setOf(origin.host))
/**
* Lowercased once. OkHttp already lower-cases and punycodes `url.host`, so an
* IDN written in Unicode here would never match — callers pass the host from
* an [HttpUrl], which is already in that form.
*/
private val hosts = allowedHosts.map { it.lowercase() }.toSet()
override fun intercept(chain: Interceptor.Chain): Response {
val request = chain.request()
val url = request.url
val allowed = url.isHttps && url.host in hosts
if (!allowed || request.header("Authorization") != null) {
return chain.proceed(request)
}
return chain.proceed(
request.newBuilder()
// ⚠️ UTF-8, not OkHttp's ISO-8859-1 default. A password with ä, ö
// or ß is otherwise sent as different bytes than the server
// expects — coming back as a 401 the user reads as "wrong
// password". The vendored BasicDigestAuthHandler does the same.
.header("Authorization", Credentials.basic(username, password, Charsets.UTF_8))
.build(),
)
}
}
@@ -120,6 +120,19 @@ object ServiceDiscovery {
if (into.none { it.url == url }) into += Candidate(url, label) if (into.none { it.url == url }) into += Candidate(url, label)
} }
/**
* The server **root** for [input] — the origin, not the DAV path.
*
* Nextcloud's Login Flow v2 lives at `index.php/login/v2` off the root, so it
* needs this rather than a discovered collection URL. A typed base URL is
* taken as typed; an email address becomes `https://<domain>`.
*/
fun serverRootFor(input: String): HttpUrl? {
val trimmed = input.trim()
asBaseUrl(trimmed)?.let { return it }
return domainOf(trimmed)?.let { "https://$it".toHttpUrlOrNull() }
}
/** The input as a base URL, or null if it is an address rather than a URL. */ /** The input as a base URL, or null if it is an address rather than a URL. */
internal fun asBaseUrl(input: String): HttpUrl? { internal fun asBaseUrl(input: String): HttpUrl? {
if (!input.startsWith("http://", ignoreCase = true) && if (!input.startsWith("http://", ignoreCase = true) &&
@@ -0,0 +1,102 @@
package de.jeanlucmakiola.caldav
import at.bitfire.dav4jvm.BasicDigestAuthHandler
import com.google.common.truth.Truth.assertThat
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.Request
import org.junit.Test
class CalDavHttpTest {
private val origin = "https://cloud.example.com/remote.php/dav/".toHttpUrl()
@Test
fun `the auth handler is scoped to the registrable domain, not the host`() {
// ⚠️ The handler compares its `domain` against
// UrlUtils.hostToDomain(request host), which keeps only the last two
// labels. Passing the full host means the comparison is
// "cloud.example.com" == "example.com" — never true — and the credential
// is withheld from every single request. That is every self-hosted
// Nextcloud, silently answering 401 forever.
val client = CalDavHttp.authenticated("Agendula", "user", "pw", origin)
val handler = client.networkInterceptors.filterIsInstance<BasicDigestAuthHandler>().single()
assertThat(handler.domain).isEqualTo("example.com")
}
@Test
fun `so the credential actually reaches the host it was made for`() {
val client = CalDavHttp.authenticated("Agendula", "user", "pw", origin)
val handler = client.networkInterceptors.filterIsInstance<BasicDigestAuthHandler>().single()
val authorised = handler.authenticateRequest(
Request.Builder().url("https://cloud.example.com/remote.php/dav/").build(),
null,
)
assertThat(authorised?.header("Authorization")).isNotNull()
}
@Test
fun `and reaches a sibling host in the same domain, which is what iCloud needs`() {
// The principal is on caldav.icloud.com and the home set on
// pNN-caldav.icloud.com. An exact-host allowlist refuses the second.
val client = CalDavHttp.authenticated(
"Agendula",
"user",
"pw",
"https://caldav.icloud.com/".toHttpUrl(),
)
val handler = client.networkInterceptors.filterIsInstance<BasicDigestAuthHandler>().single()
val authorised = handler.authenticateRequest(
Request.Builder().url("https://p42-caldav.icloud.com/1234/calendars/").build(),
null,
)
assertThat(authorised?.header("Authorization")).isNotNull()
}
@Test
fun `an unrelated domain gets nothing`() {
val client = CalDavHttp.authenticated("Agendula", "user", "pw", origin)
val handler = client.networkInterceptors.filterIsInstance<BasicDigestAuthHandler>().single()
assertThat(
handler.authenticateRequest(
Request.Builder().url("https://evil.example.org/dav/").build(),
null,
),
).isNull()
}
@Test
fun `cleartext never carries a preemptive credential`() {
val client = CalDavHttp.authenticated("Agendula", "user", "pw", origin)
val handler = client.networkInterceptors.filterIsInstance<BasicDigestAuthHandler>().single()
assertThat(
handler.authenticateRequest(
Request.Builder().url("http://cloud.example.com/dav/").build(),
null,
)?.header("Authorization"),
).isNull()
}
@Test
fun `derived clients share one connection pool`() {
// A fresh OkHttpClient per probe gives each its own pool and dispatcher
// threads: every rung of the discovery ladder reopens TLS, and the
// abandoned clients linger until GC.
val a = CalDavHttp.anonymous("Agendula")
val b = CalDavHttp.authenticated("Agendula", "user", "pw", origin)
assertThat(a.connectionPool).isSameInstanceAs(b.connectionPool)
assertThat(a.dispatcher).isSameInstanceAs(b.dispatcher)
}
@Test
fun `redirects are never followed automatically`() {
// DavResource requires it and asserts on it: redirects are followed by
// hand so a HTTPS-to-HTTP downgrade can be refused and a permanent move
// reported.
assertThat(CalDavHttp.anonymous("Agendula").followRedirects).isFalse()
assertThat(CalDavHttp.authenticated("Agendula", "u", "p", origin).followRedirects).isFalse()
}
}
@@ -1,102 +0,0 @@
package de.jeanlucmakiola.caldav
import com.google.common.truth.Truth.assertThat
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import org.junit.After
import org.junit.Before
import org.junit.Test
class PreemptiveBasicInterceptorTest {
private val server = MockWebServer()
@Before fun start() = server.start()
@After fun stop() = server.shutdown()
private fun clientFor(allowedHosts: Set<String>) = OkHttpClient.Builder()
.addInterceptor(PreemptiveBasicInterceptor("user", "pw", allowedHosts))
.build()
private fun authHeaderOf(client: OkHttpClient): String? {
server.enqueue(MockResponse().setResponseCode(200))
client.newCall(Request.Builder().url(server.url("/dav/")).build()).execute().close()
return server.takeRequest().getHeader("Authorization")
}
@Test
fun `plain HTTP never carries the credential, whatever the host list says`() {
// MockWebServer is HTTP, so this also documents why the happy path below
// is tested through the interceptor directly rather than over the wire.
assertThat(authHeaderOf(clientFor(setOf(server.hostName)))).isNull()
}
@Test
fun `a host outside the account's origin gets nothing`() {
assertThat(authHeaderOf(clientFor(setOf("someone-else.example.com")))).isNull()
}
@Test
fun `the credential is attached up front on an allowed HTTPS host`() {
// OkHttp's Authenticator is reactive-only: it costs an extra round trip on
// every request of a PROPFIND-heavy sync, and never fires at all against a
// server that answers 403 or 404 without a challenge.
val interceptor = PreemptiveBasicInterceptor("user", "pw", setOf("cloud.example.com"))
val request = Request.Builder().url("https://cloud.example.com/dav/").build()
val chain = FakeChain(request)
interceptor.intercept(chain)
assertThat(chain.proceeded!!.header("Authorization")).isEqualTo("Basic dXNlcjpwdw==")
}
@Test
fun `an existing Authorization header is never overwritten`() {
val interceptor = PreemptiveBasicInterceptor("user", "pw", setOf("cloud.example.com"))
val request = Request.Builder()
.url("https://cloud.example.com/dav/")
.header("Authorization", "Digest something")
.build()
val chain = FakeChain(request)
interceptor.intercept(chain)
assertThat(chain.proceeded!!.header("Authorization")).isEqualTo("Digest something")
}
@Test
fun `a cross-host home set does not silently receive the credential`() {
// OkHttp strips Authorization across hosts on purpose. Re-attaching it is
// something we do knowingly, per validated host — never blanket.
val interceptor = PreemptiveBasicInterceptor("user", "pw", setOf("caldav.icloud.com"))
val chain = FakeChain(Request.Builder().url("https://p42-caldav.icloud.com/dav/").build())
interceptor.intercept(chain)
assertThat(chain.proceeded!!.header("Authorization")).isNull()
}
private class FakeChain(private val request: Request) : okhttp3.Interceptor.Chain {
var proceeded: Request? = null
override fun request() = request
override fun proceed(request: Request): okhttp3.Response {
proceeded = request
return okhttp3.Response.Builder()
.request(request)
.protocol(okhttp3.Protocol.HTTP_1_1)
.code(200)
.message("OK")
.build()
}
override fun connection() = null
override fun call() = throw UnsupportedOperationException()
override fun connectTimeoutMillis() = 0
override fun withConnectTimeout(timeout: Int, unit: java.util.concurrent.TimeUnit) = this
override fun readTimeoutMillis() = 0
override fun withReadTimeout(timeout: Int, unit: java.util.concurrent.TimeUnit) = this
override fun writeTimeoutMillis() = 0
override fun withWriteTimeout(timeout: Int, unit: java.util.concurrent.TimeUnit) = this
}
}
+34
View File
@@ -277,6 +277,40 @@ before chunk 2 is done.
## Chunk 2d — the account-add UI ## Chunk 2d — the account-add UI
One flow, one back-stack entry, as a stepper rather than four destinations — the
steps are not independently reachable, and "back" from the browser step means
abandoning a server-side flow rather than popping a screen. It hangs off Settings
using the same sliding-section pattern Storage → Export already uses.
⚠️ **`PreemptiveBasicInterceptor` was deleted here**, not kept. The vendored
`BasicDigestAuthHandler` already sends Basic preemptively over HTTPS, and it also
does Digest (Baïkal defaults to it and OkHttp has none), caches which scheme
worked, and restricts by *registrable domain* rather than exact host — which is
what a cross-host home set needs, since iCloud puts the principal on
`caldav.icloud.com` and the home set on `pNN-caldav.icloud.com`. Shipping two
implementations of preemptive Basic is the same defect chunk 1 removed when
`ICalendarWriter` stopped carrying its own folding.
**A seam was added for testability, after the review found eight issues in one
untested ViewModel.** `CalDavGateway` and `AccountCreator` put the network and
the database behind interfaces, so the sign-in state machine — which decides
where five discovery outcomes lead, when a one-shot app password is spent, and
which host a credential is scoped to — is exercised without a server, a
database, a Keystore or an `AccountManager`.
**Still open, and small:** the system entry point (Settings → Accounts → Add
account → Agendula) refuses with a message pointing at the in-app flow rather
than driving it. Wiring it means `MainActivity` handling
`SyncAuthenticator.ACTION_ADD_ACCOUNT` and answering the
`AccountAuthenticatorResponse`.
**Done when:** the flow has had an on-device review and an explicit go-ahead —
`CLAUDE.md`'s rule, and this is the case it exists for.
---
## Chunk 2d — the account-add UI (original outline)
**Goal:** the app can add a CalDAV account and list its VTODO collections. No **Goal:** the app can add a CalDAV account and list its VTODO collections. No
syncing yet. syncing yet.
+3
View File
@@ -32,6 +32,8 @@ turbine = "1.2.0"
androidxHilt = "1.3.0" androidxHilt = "1.3.0"
# WorkManager: sync runs here, triggered *through* the sync-adapter framework. # WorkManager: sync runs here, triggered *through* the sync-adapter framework.
work = "2.11.2" work = "2.11.2"
# Custom Tabs, for Nextcloud Login Flow v2.
browser = "1.10.0"
navigationCompose = "2.9.0" navigationCompose = "2.9.0"
lifecycleCompose = "2.10.0" lifecycleCompose = "2.10.0"
androidxTestRules = "1.7.0" androidxTestRules = "1.7.0"
@@ -134,6 +136,7 @@ androidx-hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navig
androidx-hilt-work = { group = "androidx.hilt", name = "hilt-work", version.ref = "androidxHilt" } androidx-hilt-work = { group = "androidx.hilt", name = "hilt-work", version.ref = "androidxHilt" }
androidx-hilt-compiler = { group = "androidx.hilt", name = "hilt-compiler", version.ref = "androidxHilt" } androidx-hilt-compiler = { group = "androidx.hilt", name = "hilt-compiler", version.ref = "androidxHilt" }
androidx-work-runtime-ktx = { group = "androidx.work", name = "work-runtime-ktx", version.ref = "work" } androidx-work-runtime-ktx = { group = "androidx.work", name = "work-runtime-ktx", version.ref = "work" }
androidx-browser = { group = "androidx.browser", name = "browser", version.ref = "browser" }
# Navigation-compose (the NavHost / back stack) # Navigation-compose (the NavHost / back stack)
androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" } androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" }