sync: what the on-device round found

Everything here came from running the account flow against a real
Nextcloud rather than from reading the code.

Discovery
- A typed bare origin now gets the RFC 6764 well-known probe. It was
  returned as the only candidate, so `https://cloud.example.com` — what
  people actually type — was PROPFIND'd against the web UI, answered 405,
  and a working Nextcloud reported as "not a CalDAV server".
- A same-host HTTPS→HTTP redirect is put back on TLS instead of refused
  (`dav` change 7). A Nextcloud behind a TLS-terminating proxy without
  `overwriteprotocol` builds every redirect with http://, including the
  /.well-known/caldav hop discovery depends on. Cross-host still throws.
- Outcomes carry a `Cause` the UI translates, not the server's own words.
  "HTTP 405 Method Not Allowed" told someone entering an address nothing,
  in a language they may not read, from outside strings.xml.
- An IPv6 origin keeps its brackets: `HttpUrl.host` returns "fd00::1", so
  the rebuilt origin did not parse and a homelab address came back as
  "not an address".

Login Flow v2
- The poll response's scheme is coerced, never refused. Nextcloud returns
  the app password exactly once, so throwing there burned a live
  credential and left it dangling in the user's device list. The host
  mismatch already worked this way; the scheme now matches it.

Accounts
- The accounts screen observes Room and the sign-in state instead of
  taking a snapshot, so a sync landing — or a 401 stopping an account —
  reaches a screen that is already open.
- A per-account detail screen, and provider identity (`CalDavProvider`)
  shared with the quirk table so one list drives both the icon and the
  warning.
- The password field masks: floret-kit's `InlineTextField` gained a
  visual transformation, since `KeyboardType.Password` only tells the IME
  to drop suggestions.
This commit is contained in:
2026-09-07 18:41:45 +02:00
parent 28b2423ad9
commit b4a7fcb46e
31 changed files with 1455 additions and 280 deletions
+4
View File
@@ -69,3 +69,7 @@ Thumbs.db
# Local agent notes: machine-specific build setup and on-device rules, not
# anything the project itself depends on.
/CLAUDE.md
# Scratch backlog. Says so in its own header — dumped items get turned into
# real work, not committed as a list.
/req_changes.md
@@ -7,6 +7,7 @@ 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.flow.Flow
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
@@ -62,6 +63,9 @@ class AccountRepository @Inject constructor(
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()
/**
* Creates an account and the task lists the user chose.
*
@@ -5,7 +5,9 @@ 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
@@ -28,8 +30,12 @@ class AccountStateStore @Inject constructor(
@SyncStateDataStore private val dataStore: DataStore<Preferences>,
) {
suspend fun needingSignIn(): Set<Long> =
dataStore.data.first()[KEY].orEmpty().mapNotNull { it.toLongOrNull() }.toSet()
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()
@@ -4,15 +4,27 @@ import androidx.room.Dao
import androidx.room.Insert
import androidx.room.Query
import androidx.room.Update
import kotlinx.coroutines.flow.Flow
import kotlin.time.Instant
/** Reads and writes over `accounts`. Unused until sync lands. */
/** Reads and writes over `accounts`. */
@Dao
interface AccountDao {
@Query("SELECT * FROM accounts ORDER BY display_name")
fun all(): List<AccountEntity>
/**
* The same rows, observed.
*
* ⚠️ A one-shot read cannot show a sync: the worker writes `last_sync_at`
* from a background thread minutes after the button was pressed, so a screen
* holding a snapshot goes on saying "never synced" until the user leaves and
* comes back. Room's invalidation tracker is what closes that gap.
*/
@Query("SELECT * FROM accounts ORDER BY display_name")
fun observeAll(): Flow<List<AccountEntity>>
@Query("SELECT * FROM accounts WHERE id = :accountId")
fun account(accountId: Long): AccountEntity?
@@ -0,0 +1,211 @@
package de.jeanlucmakiola.agendula.ui.accounts
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.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.Login
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.Inventory2
import androidx.compose.material3.Icon
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.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.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: () -> Unit,
viewModel: AccountsViewModel,
) {
val accounts by viewModel.accounts.collectAsStateWithLifecycle()
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()
var removing 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 (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,
)
}
} else {
add { position ->
GroupedRow(
title = stringResource(R.string.accounts_sync_now),
position = position,
leading = { Icon(Icons.Rounded.CloudSync, contentDescription = null) },
onClick = { viewModel.syncNow(account) },
)
}
}
}
actions.forEachIndexed { index, action -> action(positionOf(index, actions.size)) }
Spacer(Modifier.height(24.dp))
// Its own group, away from the things that are safe to press twice.
GroupedRow(
title = errorTitle(stringResource(R.string.accounts_remove)),
position = Position.Alone,
leading = {
Icon(
Icons.Rounded.DeleteOutline,
contentDescription = null,
tint = MaterialTheme.colorScheme.error,
)
},
onClick = { removing = true },
)
Spacer(Modifier.height(24.dp))
}
if (removing) {
RemoveAccountPicker(
identity = identity,
onDismiss = { removing = false },
onRemove = { deleteLocalData ->
viewModel.remove(account, deleteLocalData)
removing = false
onRemoved()
},
)
}
}
/** 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) },
)
}
}
/** 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,135 @@
package de.jeanlucmakiola.agendula.ui.accounts
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.AlternateEmail
import androidx.compose.material.icons.rounded.Cloud
import androidx.compose.material.icons.rounded.CloudSync
import androidx.compose.material.icons.rounded.Dns
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
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.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 and say what they are — a mail
* service, a server, a cloud.
*/
@Composable
internal fun ProviderLogo(provider: CalDavProvider?, size: Dp = 40.dp) {
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 glyph = Modifier.size(size * GLYPH_FRACTION)
if (provider == CalDavProvider.NEXTCLOUD) {
Icon(
painter = painterResource(R.drawable.ic_provider_nextcloud),
contentDescription = provider.label,
tint = tint,
modifier = glyph,
)
} else {
Icon(
imageVector = when (provider) {
CalDavProvider.BAIKAL,
CalDavProvider.DAVICAL,
CalDavProvider.SOGO,
-> Icons.Rounded.Dns
CalDavProvider.ICLOUD -> Icons.Rounded.Cloud
null -> Icons.Rounded.CloudSync
else -> Icons.Rounded.AlternateEmail
},
contentDescription = provider?.label,
tint = tint,
modifier = glyph,
)
}
}
}
/**
* 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)
CalDavProvider.POSTEO -> Color(0xFF5E9B23)
CalDavProvider.ZOHO -> Color(0xFFE42527)
CalDavProvider.YANDEX -> Color(0xFFE03A1B)
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
@@ -2,39 +2,26 @@ package de.jeanlucmakiola.agendula.ui.accounts
import android.text.format.DateUtils
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.selection.toggleable
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.Login
import androidx.compose.material.icons.rounded.Sync
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Checkbox
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.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.Alignment
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
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.GroupedListInset
import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.Position
import de.jeanlucmakiola.floret.components.positionOf
@@ -43,15 +30,11 @@ import de.jeanlucmakiola.floret.components.positionOf
@Composable
internal fun AccountsScreen(
onAddAccount: () -> Unit,
onOpenAccount: (Long) -> Unit,
onBack: () -> Unit,
viewModel: AccountsViewModel,
) {
val accounts by viewModel.accounts.collectAsStateWithLifecycle()
var pendingRemoval by remember { mutableStateOf<AccountEntity?>(null) }
var deleteLocalData by remember { mutableStateOf(false) }
// An account can be removed from system Settings while we are away.
OnResume { viewModel.refresh() }
CollapsingScaffold(
title = stringResource(R.string.settings_section_accounts),
@@ -63,50 +46,36 @@ internal fun AccountsScreen(
Icon(
Icons.Rounded.CloudSync,
contentDescription = null,
modifier = Modifier.padding(horizontal = 16.dp),
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 = 16.dp),
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 = 16.dp),
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 = 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 {
// 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)
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)
},
title = identity.title,
summary = accountSummary(identity.user, syncState(row)),
position = positionOf(index, loaded.size),
modifier = Modifier.padding(horizontal = 16.dp),
leading = { ProviderLogo(identity.provider) },
trailing = {
if (row.needsSignIn) {
IconButton(onClick = onAddAccount) {
Icon(
Icons.Rounded.Login,
Icons.AutoMirrored.Rounded.Login,
contentDescription = stringResource(R.string.accounts_sign_in_again),
)
}
@@ -119,7 +88,7 @@ internal fun AccountsScreen(
}
}
},
onClick = { pendingRemoval = account },
onClick = { onOpenAccount(account.id) },
)
}
Spacer(Modifier.height(24.dp))
@@ -128,53 +97,32 @@ internal fun AccountsScreen(
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. The checkbox modifies the confirmation rather than turning
// it into a chooser — a full-screen picker for "are you sure" would be worse,
// and a radio list is banned outright.
pendingRemoval?.let { account ->
AlertDialog(
onDismissRequest = { pendingRemoval = null; deleteLocalData = false },
title = { Text(stringResource(R.string.accounts_remove_confirm_title, account.displayName)) },
text = {
Column {
Text(stringResource(R.string.accounts_remove_confirm_body))
Spacer(Modifier.height(16.dp))
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.toggleable(
value = deleteLocalData,
role = Role.Checkbox,
onValueChange = { deleteLocalData = it },
),
) {
Checkbox(checked = deleteLocalData, onCheckedChange = null)
Spacer(Modifier.width(12.dp))
Text(
stringResource(R.string.accounts_remove_delete_local),
style = MaterialTheme.typography.bodyMedium,
)
}
}
},
confirmButton = {
TextButton(onClick = {
viewModel.remove(account, deleteLocalData)
pendingRemoval = null
deleteLocalData = false
}) { Text(stringResource(R.string.accounts_remove_confirm_action)) }
},
dismissButton = {
TextButton(onClick = { pendingRemoval = null; deleteLocalData = false }) {
Text(stringResource(android.R.string.cancel))
}
},
)
}
}
/**
* 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 -> stringResource(R.string.accounts_sync_failed)
row.account.lastSyncAt != null -> DateUtils.getRelativeTimeSpanString(
row.account.lastSyncAt.toEpochMilliseconds(),
System.currentTimeMillis(),
DateUtils.MINUTE_IN_MILLIS,
).toString()
else -> stringResource(R.string.accounts_never_synced)
}
/** 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)
@@ -7,9 +7,10 @@ import de.jeanlucmakiola.agendula.data.sync.AccountRepository
import de.jeanlucmakiola.agendula.data.sync.AccountStateStore
import de.jeanlucmakiola.agendula.data.sync.SyncTrigger
import de.jeanlucmakiola.agendula.data.tasks.room.AccountEntity
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import javax.inject.Inject
@@ -23,21 +24,26 @@ class AccountsViewModel @Inject constructor(
/** An account row, plus the one thing the row cannot read from the entity. */
data class AccountRow(val account: AccountEntity, val needsSignIn: Boolean)
private val _accounts = MutableStateFlow<List<AccountRow>?>(null)
/** `null` until the first load, so the empty state does not flash. */
val accounts: StateFlow<List<AccountRow>?> = _accounts.asStateFlow()
init {
refresh()
}
fun refresh() {
viewModelScope.launch {
val stopped = accountState.needingSignIn()
_accounts.value = repository.all().map { AccountRow(it, it.id in stopped) }
}
}
/**
* `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(),
) { accounts, stopped ->
accounts.map { AccountRow(it, it.id in stopped) }
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(STOP_TIMEOUT_MILLIS),
initialValue = null,
)
/**
* The app's own sync trigger.
@@ -54,9 +60,13 @@ class AccountsViewModel @Inject constructor(
}
fun remove(account: AccountEntity, deleteLocalData: Boolean) {
viewModelScope.launch {
repository.remove(account.id, account.displayName, deleteLocalData)
refresh()
}
// No refresh: the row disappears because the query behind `accounts`
// re-emits.
viewModelScope.launch { repository.remove(account.id, account.displayName, deleteLocalData) }
}
private companion object {
/** Survives a configuration change without re-subscribing. */
const val STOP_TIMEOUT_MILLIS = 5_000L
}
}
@@ -4,21 +4,29 @@ import android.content.ActivityNotFoundException
import android.content.Intent
import androidx.browser.customtabs.CustomTabsIntent
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.background
import androidx.compose.foundation.shape.CircleShape
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.automirrored.rounded.ArrowBack
import androidx.compose.material.icons.rounded.CloudOff
import androidx.compose.material.icons.rounded.Checklist
import androidx.compose.material.icons.rounded.CloudSync
import androidx.compose.material.icons.rounded.Lock
import androidx.compose.material.icons.rounded.OpenInBrowser
import androidx.compose.material3.Button
import androidx.compose.material3.Checkbox
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
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
@@ -26,21 +34,31 @@ 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.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 androidx.core.net.toUri
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.hilt.navigation.compose.hiltViewModel
import de.jeanlucmakiola.agendula.R
import de.jeanlucmakiola.caldav.CalDavDiscovery
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.InlineTextField
import de.jeanlucmakiola.floret.components.OnboardingProgress
import de.jeanlucmakiola.floret.components.OnboardingScaffold
import de.jeanlucmakiola.floret.components.OnboardingSpace
import de.jeanlucmakiola.floret.components.Position
import de.jeanlucmakiola.floret.components.SelectedCheck
import de.jeanlucmakiola.floret.components.positionOf
/**
@@ -86,26 +104,56 @@ internal fun AddAccountScreen(
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()
// 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()
}
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
if (position != null) {
OnboardingProgress(
step = position,
total = ADD_ACCOUNT_STEPS,
label = stringResource(
R.string.add_account_step_of,
position,
ADD_ACCOUNT_STEPS,
),
)
}
},
navigationIcon = {
IconButton(onClick = abandon) {
Icon(
Icons.AutoMirrored.Rounded.ArrowBack,
contentDescription = stringResource(R.string.back),
)
}
},
actions = { StepActions(state, viewModel) },
) {
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
Text(
fatal,
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.EnterServer -> ServerStep(step, state.quirk, viewModel)
is AddAccountStep.Working -> WorkingStep(step)
@@ -117,6 +165,74 @@ internal fun AddAccountScreen(
}
}
/** Which of the flow's visible steps this is, or null for one that has no place. */
private val AddAccountStep.position: Int?
get() = when (this) {
is AddAccountStep.EnterServer -> 1
is AddAccountStep.EnterCredentials, is AddAccountStep.WaitingForBrowser -> 2
is AddAccountStep.ChooseLists -> 3
// 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
}
private const val ADD_ACCOUNT_STEPS = 3
/** The mark for the step, in the family's tonal circle. */
@Composable
private fun StepHero(state: AddAccountUiState) {
val icon = when {
state.fatal != null -> Icons.Rounded.CloudOff
state.step is AddAccountStep.EnterCredentials -> Icons.Rounded.Lock
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.EnterServer ->
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.ChooseLists ->
R.string.add_account_lists_title to R.string.add_account_lists_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))
}
@Composable
private fun ServerStep(
step: AddAccountStep.EnterServer,
@@ -124,69 +240,219 @@ private fun ServerStep(
viewModel: AddAccountViewModel,
) {
Column(Modifier.padding(horizontal = 16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
OutlinedTextField(
FieldRow(
label = stringResource(R.string.add_account_server_label),
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(),
placeholder = stringResource(R.string.add_account_server_hint),
error = step.error?.let { stringResource(it.message) },
keyboardType = KeyboardType.Uri,
onImeAction = viewModel::onServerSubmitted,
)
// 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(
// 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,
hint = stringResource(R.string.add_account_password_hint),
keyboardType = KeyboardType.Password,
imeAction = ImeAction.Go,
),
modifier = Modifier.fillMaxWidth(),
onImeAction = viewModel::onCredentialsSubmitted,
)
}
}
}
/**
* 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.
*/
private 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 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
private 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 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 four 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,
)
Button(
onClick = viewModel::onCredentialsSubmitted,
return
}
when (val step = state.step) {
is AddAccountStep.EnterServer -> PrimaryAction(
label = stringResource(R.string.add_account_continue),
enabled = step.input.isNotBlank(),
onClick = viewModel::onServerSubmitted,
)
is AddAccountStep.EnterCredentials -> PrimaryAction(
label = stringResource(R.string.add_account_sign_in),
enabled = step.username.isNotBlank() && step.password.isNotEmpty(),
modifier = Modifier.fillMaxWidth(),
) { Text(stringResource(R.string.add_account_sign_in)) }
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.WaitingForBrowser -> {
// A failed flow gets the primary action: there is nothing left to
// wait for, and the way forward is a password.
if (step.error != null) {
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)) }
}
}
is AddAccountStep.Working, AddAccountStep.Done -> Unit
}
}
/** The shell's primary action shape, so every step ends identically. */
@Composable
private 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)
}
}
@Composable
private fun BrowserStep(step: AddAccountStep.WaitingForBrowser, viewModel: AddAccountViewModel) {
Column(Modifier.padding(horizontal = 16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
// ⚠️ 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) {
@@ -195,7 +461,11 @@ private fun BrowserStep(step: AddAccountStep.WaitingForBrowser, viewModel: AddAc
Icon(Icons.Rounded.CloudOff, contentDescription = null)
}
},
title = stringResource(R.string.add_account_browser_title),
title = if (step.error == null) {
stringResource(R.string.add_account_browser_title)
} else {
stringResource(R.string.add_account_browser_failed_title)
},
text = step.error ?: stringResource(R.string.add_account_browser_body),
)
@@ -209,10 +479,6 @@ private fun BrowserStep(step: AddAccountStep.WaitingForBrowser, viewModel: AddAc
)
}
TextButton(
onClick = viewModel::onBrowserCancelled,
modifier = Modifier.fillMaxWidth(),
) { Text(stringResource(R.string.add_account_browser_use_password)) }
}
}
@@ -230,11 +496,7 @@ private fun WorkingStep(step: AddAccountStep.Working) {
@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),
)
// The heading is the shell's; this step only lists.
step.collections.forEachIndexed { index, collection ->
val checked = collection.url in step.selected
GroupedRow(
@@ -246,27 +508,13 @@ private fun ListsStep(step: AddAccountStep.ChooseLists, viewModel: AddAccountVie
},
position = positionOf(index, step.collections.size),
selected = checked,
modifier = Modifier.padding(horizontal = 16.dp),
trailing = {
Checkbox(checked = checked, onCheckedChange = null)
},
// 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))
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
@@ -25,7 +25,11 @@ import javax.inject.Inject
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 EnterServer(
val input: String = "",
/** A cause, not a message — the screen owns the wording. */
val error: CalDavDiscovery.Outcome.Cause? = null,
) : AddAccountStep
data class Working(val message: String) : AddAccountStep
@@ -134,9 +138,9 @@ class AddAccountViewModel @Inject constructor(
// would make it permanently unreachable.
CalDavDiscovery.Outcome.Unauthenticated -> offerSignIn()
is CalDavDiscovery.Outcome.NotCalDav -> backToServer(outcome.reason)
is CalDavDiscovery.Outcome.NotCalDav -> backToServer(outcome.cause)
is CalDavDiscovery.Outcome.Failed -> backToServer(outcome.reason)
is CalDavDiscovery.Outcome.Failed -> backToServer(outcome.cause)
}
}
}
@@ -171,15 +175,15 @@ class AddAccountViewModel @Inject constructor(
)
}
is CalDavDiscovery.Outcome.NotCalDav -> backToServer(outcome.reason)
is CalDavDiscovery.Outcome.Failed -> backToServer(outcome.reason)
is CalDavDiscovery.Outcome.NotCalDav -> backToServer(outcome.cause)
is CalDavDiscovery.Outcome.Failed -> backToServer(outcome.cause)
}
}
}
/** 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 root = serverRoot ?: return backToServer(CalDavDiscovery.Outcome.Cause.NOT_AN_ADDRESS)
val flow = gateway.startLoginFlow(root)
if (flow == null) {
@@ -230,7 +234,7 @@ class AddAccountViewModel @Inject constructor(
if (outcome is CalDavDiscovery.Outcome.Found) {
onDiscovered(outcome)
} else {
backToServer("Signed in, but no task lists could be read.")
backToServer(CalDavDiscovery.Outcome.Cause.NO_CALENDARS)
}
return@launch
}
@@ -371,8 +375,16 @@ class AddAccountViewModel @Inject constructor(
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))
/**
* ⚠️ Carries the [CalDavDiscovery.Outcome.Cause], never the detail string.
*
* The detail is the server's own words — routinely a bare status line, often
* in a language the user does not read, and always outside `strings.xml`.
* The screen turns the cause into a translated sentence; nothing renders the
* detail.
*/
private fun backToServer(cause: CalDavDiscovery.Outcome.Cause) = _state.update {
it.copy(step = AddAccountStep.EnterServer(input = typedInput, error = cause))
}
private fun browserFailed(reason: String) = _state.update {
@@ -12,6 +12,7 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.agendula.R
import de.jeanlucmakiola.floret.components.CollapsingScaffold
import de.jeanlucmakiola.floret.components.GroupedListInset
import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.positionOf
@@ -35,7 +36,7 @@ internal fun LicencesScreen(onBack: () -> Unit) {
stringResource(R.string.licences_intro),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 16.dp),
modifier = Modifier.padding(horizontal = GroupedListInset),
)
Spacer(Modifier.height(16.dp))
@@ -44,7 +45,6 @@ internal fun LicencesScreen(onBack: () -> Unit) {
title = attribution.name,
summary = "${attribution.copyright} · ${attribution.licence.spdxId}",
position = positionOf(index, OpenSourceLicences.ALL.size),
modifier = Modifier.padding(horizontal = 16.dp),
onClick = { uriHandler.openUri(attribution.sourceUrl) },
)
}
@@ -54,7 +54,7 @@ internal fun LicencesScreen(onBack: () -> Unit) {
stringResource(R.string.licences_footer),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 16.dp),
modifier = Modifier.padding(horizontal = GroupedListInset),
)
Spacer(Modifier.height(24.dp))
}
@@ -99,6 +99,7 @@ import de.jeanlucmakiola.floret.identity.expandEnter
import de.jeanlucmakiola.floret.reminders.ReminderOverride
import de.jeanlucmakiola.floret.reminders.reminderOverrideFor
import de.jeanlucmakiola.agendula.ui.common.OnResume
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.AddAccountScreen
@@ -113,14 +114,15 @@ private enum class SettingsSection {
Export,
Accounts,
AddAccount,
Account,
Licences,
;
/** Where back goes: Export is opened from Storage, AddAccount from Accounts. */
/** Where back goes: Export is opened from Storage, the account screens from Accounts. */
val parent: SettingsSection?
get() = when (this) {
Export -> Storage
AddAccount -> Accounts
AddAccount, Account -> Accounts
else -> null
}
}
@@ -148,6 +150,8 @@ fun SettingsScreen(
var section by rememberSaveable { mutableStateOf<SettingsSection?>(null) }
// Hoisted so the add flow can refresh the list it returns to.
val accountsViewModel: AccountsViewModel = hiltViewModel()
// Which account the detail screen is showing; the section alone cannot say.
var openAccount by rememberSaveable { mutableStateOf<Long?>(null) }
// Inside a sub-screen, system back (button or gesture) returns to the hub
// rather than popping the whole Settings destination to the lists overview.
@@ -189,6 +193,10 @@ fun SettingsScreen(
SlideInSection(visible = accountsOpen) {
AccountsScreen(
onAddAccount = { section = SettingsSection.AddAccount },
onOpenAccount = {
openAccount = it
section = SettingsSection.Account
},
onBack = { section = null },
viewModel = accountsViewModel,
)
@@ -196,15 +204,24 @@ fun SettingsScreen(
SlideInSection(visible = section == SettingsSection.Licences) {
LicencesScreen(onBack = { section = null })
}
SlideInSection(visible = section == SettingsSection.Account) {
openAccount?.let { id ->
AccountDetailScreen(
accountId = id,
onBack = { section = SettingsSection.Accounts },
onRemoved = { section = SettingsSection.Accounts },
onSignInAgain = { section = SettingsSection.AddAccount },
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
},
// 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 = { section = SettingsSection.Accounts },
onBack = { section = SettingsSection.Accounts },
)
}
@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
The Nextcloud mark: three tangent circles, the middle one larger. Outlined
rather than filled so it sits in the same monochrome family as every other
leading glyph, and tinted by the caller so it follows the theme.
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="192"
android:viewportHeight="192">
<path
android:pathData="M66,96 a30,30 0 1,0 60,0 a30,30 0 1,0 -60,0"
android:strokeWidth="12"
android:strokeColor="#FF000000" />
<path
android:pathData="M22,96 a22,22 0 1,0 44,0 a22,22 0 1,0 -44,0"
android:strokeWidth="12"
android:strokeColor="#FF000000" />
<path
android:pathData="M126,96 a22,22 0 1,0 44,0 a22,22 0 1,0 -44,0"
android:strokeWidth="12"
android:strokeColor="#FF000000" />
</vector>
+22 -4
View File
@@ -309,15 +309,33 @@
<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_remove_body">%1$s stops syncing with this device. Nothing is deleted from the server.</string>
<string name="accounts_remove_keep">Remove, keep the tasks</string>
<string name="accounts_remove_keep_body">Its lists stay on this device as device-only lists.</string>
<string name="accounts_remove_wipe">Remove and delete the tasks</string>
<string name="accounts_remove_wipe_body">Also deletes this account\u2019s lists and tasks from this device.</string>
<string name="accounts_never_synced">Never synced</string>
<string name="accounts_last_sync">Last sync</string>
<string name="accounts_generic_provider">CalDAV account</string>
<string name="accounts_sync_failed">Last sync didn\u2019t finish</string>
<string name="accounts_sync_now">Sync now</string>
<string name="add_account_browser_failed_title">Sign-in didn\u2019t finish</string>
<string name="add_account_step_of">Step %1$d of %2$d</string>
<string name="add_account_server_title">Where are your tasks?</string>
<string name="add_account_server_body">Enter the address of your CalDAV server, or the email address you use with it.</string>
<string name="add_account_credentials_title">Sign in</string>
<string name="add_account_credentials_body">Use an app password if your provider offers one \u2014 it can be revoked without changing your account password.</string>
<string name="add_account_lists_body">Pick the task lists to keep in sync. You can change this later.</string>
<string name="add_account_error_not_an_address">That doesn\u2019t look like a web address. Try something like cloud.example.com.</string>
<string name="add_account_error_not_dav">That address answered, but it isn\u2019t a CalDAV server. If you pasted the website, try the server address your provider gives for calendars.</string>
<string name="add_account_error_no_calendar">That server doesn\u2019t offer calendars. Check the address with your provider.</string>
<string name="add_account_error_unreachable">Couldn\u2019t reach that server. Check the address and your connection.</string>
<string name="add_account_error_insecure">That address is unencrypted. Agendula only sends your password over https.</string>
<string name="add_account_error_no_calendars">Signed in, but this account has no task lists.</string>
<string name="add_account_error_server">The server ran into a problem. Try again in a moment.</string>
<string name="accounts_needs_sign_in">Sign in again to keep syncing</string>
<string name="accounts_sign_in_again">Sign in again</string>
<string name="accounts_remove_delete_local">Also delete this account\u2019s tasks from this device</string>
<string name="accounts_summary">%1$s \u00b7 %2$s</string>
<string name="sync_notification_channel">Syncing</string>
<string name="sync_notification_title">Syncing tasks</string>
@@ -85,7 +85,10 @@ class AddAccountViewModelTest {
@Test
fun `a server that is not CalDAV says so on the address step`() = runTest(dispatcher) {
gateway.discoveryOutcomes += CalDavDiscovery.Outcome.NotCalDav("no calendar-access")
gateway.discoveryOutcomes += CalDavDiscovery.Outcome.NotCalDav(
CalDavDiscovery.Outcome.Cause.NO_CALENDAR_SUPPORT,
"no calendar-access",
)
val vm = viewModel()
vm.onServerInputChanged("https://example.com/")
@@ -93,7 +96,9 @@ class AddAccountViewModelTest {
advanceUntilIdle()
val step = vm.state.value.step as AddAccountStep.EnterServer
assertThat(step.error).contains("calendar-access")
// ⚠️ A cause, never the server's own words — the screen owns the
// wording, and a raw status line bypasses strings.xml entirely.
assertThat(step.error).isEqualTo(CalDavDiscovery.Outcome.Cause.NO_CALENDAR_SUPPORT)
// The input survives, so the user can correct it rather than retype it.
assertThat(step.input).isEqualTo("https://example.com/")
}
@@ -333,7 +338,10 @@ class AddAccountViewModelTest {
): CalDavDiscovery.Outcome {
discoveries += Call(target, credentials)
return discoveryOutcomes.removeFirstOrNull()
?: CalDavDiscovery.Outcome.Failed("no outcome queued")
?: CalDavDiscovery.Outcome.Failed(
CalDavDiscovery.Outcome.Cause.SERVER_ERROR,
"no outcome queued",
)
}
override suspend fun startLoginFlow(server: HttpUrl) = loginFlow
@@ -3,10 +3,12 @@ package de.jeanlucmakiola.caldav
import at.bitfire.dav4jvm.DavResource
import at.bitfire.dav4jvm.Response
import at.bitfire.dav4jvm.property.CalendarHomeSet
import at.bitfire.dav4jvm.exception.HttpException
import at.bitfire.dav4jvm.property.CurrentUserPrincipal
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.OkHttpClient
import java.io.IOException
/**
* RFC 6764 discovery: from what the user typed to the list of task collections.
@@ -75,9 +77,61 @@ class CalDavDiscovery(
/** A 200 whose body says the credentials were not accepted (RFC 5397 §3). */
data object Unauthenticated : Outcome
data class NotCalDav(val reason: String) : Outcome
data class NotCalDav(val cause: Cause, val detail: String) : Outcome
data class Failed(val reason: String) : Outcome
data class Failed(val cause: Cause, val detail: String) : Outcome
/**
* Why discovery ended, in a form the UI can translate.
*
* ⚠️ The UI must render *this*, never [Failed.detail]. A server's own
* words are untranslatable, frequently in a language the user does not
* read, and quite often a bare status line — "HTTP 405 Method Not
* Allowed" tells someone entering their address precisely nothing, and
* bypasses `strings.xml` entirely. [detail] exists for logs and bug
* reports, and is never shown.
*/
enum class Cause {
/** The address is not a URL, or names nothing we can look up. */
NOT_AN_ADDRESS,
/** Reached something, but it does not speak WebDAV at all. */
NOT_A_DAV_SERVER,
/** Speaks WebDAV but not CalDAV — a file-sharing endpoint, say. */
NO_CALENDAR_SUPPORT,
/** Nothing answered: DNS, connection refused, TLS, timeout. */
UNREACHABLE,
/** The address, or where it redirects, is plain HTTP. */
INSECURE,
/** Signed in, but the account exposes no calendar home. */
NO_CALENDARS,
/** The server answered, and the answer was an error of its own. */
SERVER_ERROR,
}
}
/**
* Classifies a transport or protocol failure for the UI.
*
* ⚠️ **405 is the interesting one.** It is what an ordinary web server
* answers to `PROPFIND`, which makes it the single most likely response to
* someone typing their *website* instead of their CalDAV address — and it
* means exactly "this is not a DAV server". Surfacing it as "HTTP 405 Method
* Not Allowed" hands the user a status code where they needed a sentence.
*/
private fun causeOf(error: Throwable): Outcome.Cause = when {
error is HttpException -> when (error.code) {
METHOD_NOT_ALLOWED, NOT_IMPLEMENTED, NOT_FOUND -> Outcome.Cause.NOT_A_DAV_SERVER
else -> Outcome.Cause.SERVER_ERROR
}
// Everything that never got an answer: DNS, refused, TLS, timeout.
error is IOException -> Outcome.Cause.UNREACHABLE
else -> Outcome.Cause.SERVER_ERROR
}
/**
@@ -91,16 +145,18 @@ class CalDavDiscovery(
ServiceDiscovery.asBaseUrl(input)?.let { typed ->
if (!typed.isHttps && !allowCleartext) {
return Outcome.Failed(
"\"$input\" is an unencrypted http:// address. Credentials are never sent " +
"over cleartext, so this can only ever answer \"not authorised\".",
Outcome.Cause.INSECURE,
"\"$input\" is an unencrypted http:// address",
)
}
}
val candidates = ServiceDiscovery.candidatesFor(input, dns)
if (candidates.isEmpty()) return Outcome.Failed("could not read \"$input\" as an address or URL")
if (candidates.isEmpty()) {
return Outcome.Failed(Outcome.Cause.NOT_AN_ADDRESS, "no candidates for \"$input\"")
}
var lastFailure: Outcome = Outcome.Failed("no candidate answered")
var lastFailure: Outcome = Outcome.Failed(Outcome.Cause.UNREACHABLE, "no candidate answered")
for (candidate in candidates) {
when (val outcome = probe(candidate.url)) {
is Outcome.Found, is Outcome.NeedsAuthentication, Outcome.Unauthenticated -> return outcome
@@ -135,7 +191,7 @@ class CalDavDiscovery(
// 401 is not a failure — iCloud and Zoho answer it from
// /.well-known/caldav, which *is* the DAV root, and it is RFC-legal.
if (isUnauthorized(error)) return Outcome.NeedsAuthentication(listOf(base.host))
return Outcome.Failed(error.message ?: error.toString())
return Outcome.Failed(causeOf(error), error.message ?: error.toString())
}
// ⚠️ RFC 5397 §3: a 200 carrying <D:unauthenticated/> means the
@@ -148,17 +204,29 @@ class CalDavDiscovery(
val href = principalHref
?: return if (davCapabilities.contains("calendar-access")) {
Outcome.Failed("server advertises calendar-access but returned no principal")
Outcome.Failed(
Outcome.Cause.NO_CALENDAR_SUPPORT,
"advertises calendar-access but returned no principal",
)
} else {
Outcome.NotCalDav("no DAV:current-user-principal, and no calendar-access in OPTIONS")
Outcome.NotCalDav(
Outcome.Cause.NOT_A_DAV_SERVER,
"no DAV:current-user-principal, and no calendar-access in OPTIONS",
)
}
if (davCapabilities.isNotEmpty() && !davCapabilities.contains("calendar-access")) {
return Outcome.NotCalDav("OPTIONS advertises ${davCapabilities.joinToString()} but not calendar-access")
return Outcome.NotCalDav(
Outcome.Cause.NO_CALENDAR_SUPPORT,
"OPTIONS advertises ${davCapabilities.joinToString()} but not calendar-access",
)
}
val principal = resource.location.resolve(href)
?: return Outcome.Failed("principal href \"$href\" is not a usable URL")
?: return Outcome.Failed(
Outcome.Cause.SERVER_ERROR,
"principal href \"$href\" is not a usable URL",
)
return fromPrincipal(principal, movedTo = resource.permanentLocation)
}
@@ -178,10 +246,12 @@ class CalDavDiscovery(
}
homeSetResult.exceptionOrNull()?.let { error ->
if (isUnauthorized(error)) return Outcome.NeedsAuthentication(listOf(principal.host))
return Outcome.Failed(error.message ?: error.toString())
return Outcome.Failed(causeOf(error), error.message ?: error.toString())
}
if (homeSets.isEmpty()) return Outcome.Failed("principal has no calendar-home-set")
if (homeSets.isEmpty()) {
return Outcome.Failed(Outcome.Cause.NO_CALENDARS, "principal has no calendar-home-set")
}
// Cross-host is legal and required, but never over plain HTTP: the
// credentials follow the home set, and a downgrade would send them in the
@@ -189,7 +259,10 @@ class CalDavDiscovery(
val crossHost = homeSets.filter { it.host != principal.host }
val insecure = homeSets.filter { principal.isHttps && !it.isHttps }
if (insecure.isNotEmpty()) {
return Outcome.Failed("calendar-home-set downgrades to HTTP: ${insecure.first()}")
return Outcome.Failed(
Outcome.Cause.INSECURE,
"calendar-home-set downgrades to HTTP: ${insecure.first()}",
)
}
val collections = linkedMapOf<HttpUrl, TaskCollection>()
@@ -228,6 +301,7 @@ class CalDavDiscovery(
Outcome.NeedsAuthentication(needAuth.map { it.url.host }.distinct())
} else {
Outcome.Failed(
Outcome.Cause.NO_CALENDARS,
failures.firstOrNull()?.reason ?: "no calendar-home-set could be listed",
)
}
@@ -249,5 +323,9 @@ class CalDavDiscovery(
companion object {
/** `https://host/path` → the URL, or null. Convenience for callers. */
fun url(value: String): HttpUrl? = value.toHttpUrlOrNull()
private const val NOT_FOUND = 404
private const val METHOD_NOT_ALLOWED = 405
private const val NOT_IMPLEMENTED = 501
}
}
@@ -0,0 +1,91 @@
package de.jeanlucmakiola.caldav
import okhttp3.HttpUrl
/**
* Which CalDAV service or server software an account talks to.
*
* Read off the two things an account already stores, so nothing extra is asked
* of the server and no column has to be added: the **host**, which names a
* hosted service outright, and the **principal URL's path**, whose shape is a
* fingerprint of the software behind it.
*
* [hosted] is what decides how an account is *named* on screen. A hosted
* service's host is boilerplate (`caldav.fastmail.com` for everyone), so its
* brand is the name; self-hosted software runs on the user's own host, which is
* the only thing that tells two of them apart.
*/
enum class CalDavProvider(
val label: String,
val hosted: Boolean,
internal val domains: Set<String> = emptySet(),
internal val principalMarkers: Set<String> = emptySet(),
) {
/**
* ownCloud serves `/remote.php/dav/` too and cannot be told apart from here.
* Nextcloud is the far commoner of the two and the only one the add flow has
* a browser login for, so the mark goes to it — and because a self-hosted
* account is titled by its host, the name "Nextcloud" is never written next
* to an ownCloud server, only its mark.
*/
NEXTCLOUD("Nextcloud", hosted = false, principalMarkers = setOf("/remote.php/dav/")),
BAIKAL("Baïkal", hosted = false, principalMarkers = setOf("/dav.php/")),
DAVICAL("DAViCal", hosted = false, principalMarkers = setOf("/caldav.php/")),
SOGO("SOGo", hosted = false, principalMarkers = setOf("/sogo/dav/")),
FASTMAIL(
"Fastmail",
hosted = true,
domains = setOf("fastmail.com", "fastmail.fm", "messagingengine.com"),
),
ICLOUD("iCloud", hosted = true, domains = setOf("icloud.com", "me.com", "mac.com")),
GOOGLE("Google", hosted = true, domains = setOf("gmail.com", "googlemail.com", "google.com")),
MAILBOX_ORG("mailbox.org", hosted = true, domains = setOf("mailbox.org")),
POSTEO("Posteo", hosted = true, domains = setOf("posteo.de", "posteo.net")),
ZOHO("Zoho", hosted = true, domains = setOf("zoho.com", "zoho.eu")),
YANDEX("Yandex", hosted = true, domains = setOf("yandex.ru", "yandex.com")),
;
companion object {
/** The service a host belongs to, if it is one we know by name. */
fun forHost(host: String): CalDavProvider? {
val lower = host.lowercase().trimEnd('.')
return entries.firstOrNull { provider ->
provider.domains.any { lower == it || lower.endsWith(".$it") }
}
}
/** The service behind an email address or a typed URL, if any. */
fun forInput(input: String): CalDavProvider? {
val host = ServiceDiscovery.asBaseUrl(input)?.host
?: ServiceDiscovery.domainOf(input)
?: return null
return forHost(host)
}
/**
* The provider behind a principal URL: the host first, because a service
* we know by name is not in doubt, then the path shape.
*/
fun forPrincipal(url: HttpUrl): CalDavProvider? =
forHost(url.host) ?: forPath(url.encodedPath)
private fun forPath(path: String): CalDavProvider? {
val lower = path.lowercase()
return entries.firstOrNull { provider ->
provider.principalMarkers.any { it in lower }
}
}
}
}
@@ -162,14 +162,14 @@ class NextcloudLoginFlow(
val root = json.parseToJsonElement(body).jsonObject
val server = root["server"]?.jsonPrimitive?.content?.toHttpUrlOrNull()
?: error("no server URL in poll response")
// Scheme only. A host mismatch here must never discard the credentials:
// the 200 is returned exactly once — the server deletes the row inside
// poll() before returning — so throwing would burn the app password and
// force the user through the whole flow again.
requireSecureOrigin(flow.pollEndpoint, server)
// ⚠️ Coerced, never refused — neither the host nor the scheme may discard
// the credentials. The 200 is returned exactly once (the server deletes
// the row inside poll() before answering), so a throw here burns a live
// app password and leaves it dangling in the user's device list.
val secureServer = secureOrigin(flow.pollEndpoint, server)
PollResult.Approved(
Credentials(
server = server,
server = secureServer,
// ⚠️ loginName is what the user typed — possibly an email, an
// LDAP-derived value, or the right name in the wrong case. It is
// the Basic auth username and nothing else. Interpolating it into
@@ -182,17 +182,14 @@ class NextcloudLoginFlow(
}.getOrElse { PollResult.Failed(it.message ?: it.toString()) }
/**
* The endpoint is generated from `overwrite.cli.url` / `overwriteprotocol` /
* These URLs are generated from `overwrite.cli.url` / `overwriteprotocol` /
* `trusted_proxies`, which are misconfigured on a large fraction of
* self-hosted installs — so it is validated rather than trusted verbatim.
* self-hosted installs — so they are validated rather than trusted verbatim.
*
* A downgrade to `http` is refused outright: the poll token is exchanged for a
* long-lived app password, which makes it a credential-grade secret.
*/
/**
* A downgrade to `http` is **fatal**. The poll token is exchanged for a
* long-lived app password and the login URL takes the account password, so
* both are credential-grade.
* A downgrade to `http` is **fatal here and only here**: this runs *before*
* the user has approved anything, and the login URL is where they type their
* account password. There is nothing to lose by refusing, and everything to
* lose by not.
*/
internal fun requireSecureOrigin(expected: HttpUrl, actual: HttpUrl) {
if (expected.isHttps && !actual.isHttps) {
@@ -200,6 +197,29 @@ class NextcloudLoginFlow(
}
}
/**
* The same downgrade, on the *poll response*, where refusing is the wrong
* answer.
*
* ⚠️ By this point the credential has already been issued, and Nextcloud
* returns it **exactly once** — the row is deleted inside `poll()` before it
* answers. Throwing here does not protect anything: it destroys a live app
* password, leaves one dangling in the user's device list, and sends them
* through the whole flow again. The comment on the host mismatch above says
* precisely this, and the scheme deserves the same treatment.
*
* Coercing is strictly safer than either alternative, because the invariant
* that matters is *what we do next*: we only ever talk to the coerced URL, so
* the credential never travels in cleartext regardless of what the server
* put in the JSON.
*/
internal fun secureOrigin(expected: HttpUrl, actual: HttpUrl): HttpUrl =
if (expected.isHttps && !actual.isHttps) {
actual.newBuilder().scheme("https").build()
} else {
actual
}
/** A different host than the user typed — reported, not refused. */
internal fun hostMismatchOf(expected: HttpUrl, actual: HttpUrl): HostMismatch? =
if (expected.host != actual.host) HostMismatch(expected.host, actual.host) else null
@@ -9,14 +9,17 @@ import okhttp3.HttpUrl
* account password"* is the single most common support ticket any CalDAV client
* inherits. Detecting it at account-add time by domain turns a dead end into one
* sentence of instruction.
*
* The domains themselves live on [CalDavProvider] — one list, so a provider the
* accounts screen can mark is also a provider this can warn about.
*/
enum class ServerQuirk(val domains: Set<String>) {
enum class ServerQuirk {
/** Fastmail: needs an app password, and CalDAV is not on the Basic plan. */
FASTMAIL_APP_PASSWORD(setOf("fastmail.com", "fastmail.fm", "messagingengine.com")),
FASTMAIL_APP_PASSWORD,
/** iCloud: app-specific password, and 2FA must be on to mint one. */
ICLOUD_APP_SPECIFIC_PASSWORD(setOf("icloud.com", "me.com", "mac.com")),
ICLOUD_APP_SPECIFIC_PASSWORD,
/**
* Google: OAuth2-only, and it supports neither VTODO nor MKCALENDAR — its own
@@ -24,7 +27,7 @@ enum class ServerQuirk(val domains: Set<String>) {
* drops it as a target, so this is a refusal with an explanation rather than
* a 401 the user cannot act on.
*/
GOOGLE_UNSUPPORTED(setOf("gmail.com", "googlemail.com", "google.com")),
GOOGLE_UNSUPPORTED,
/**
* Nextcloud's brute-force protection throttles then 429s **per source IP**, so
@@ -32,27 +35,26 @@ enum class ServerQuirk(val domains: Set<String>) {
* clients on that network. Not domain-detectable; set when a server identifies
* itself. Kept here so the engine has one place to ask.
*/
NEXTCLOUD_BRUTE_FORCE_PROTECTED(emptySet()),
NEXTCLOUD_BRUTE_FORCE_PROTECTED,
;
companion object {
/** The quirk implied by an email address or a URL host, if any. */
fun forInput(input: String): ServerQuirk? {
val host = ServiceDiscovery.asBaseUrl(input)?.host
?: ServiceDiscovery.domainOf(input)
?: return null
return forHost(host)
}
fun forInput(input: String): ServerQuirk? = CalDavProvider.forInput(input)?.quirk
fun forHost(host: String): ServerQuirk? {
val lower = host.lowercase().trimEnd('.')
return entries.firstOrNull { quirk ->
quirk.domains.any { lower == it || lower.endsWith(".$it") }
}
}
fun forHost(host: String): ServerQuirk? = CalDavProvider.forHost(host)?.quirk
fun forUrl(url: HttpUrl): ServerQuirk? = forHost(url.host)
private val CalDavProvider.quirk: ServerQuirk?
get() = when (this) {
CalDavProvider.FASTMAIL -> FASTMAIL_APP_PASSWORD
CalDavProvider.ICLOUD -> ICLOUD_APP_SPECIFIC_PASSWORD
CalDavProvider.GOOGLE -> GOOGLE_UNSUPPORTED
CalDavProvider.NEXTCLOUD -> NEXTCLOUD_BRUTE_FORCE_PROTECTED
else -> null
}
}
/** True when discovery should not even be attempted. */
@@ -71,9 +71,43 @@ object ServiceDiscovery {
val trimmed = input.trim()
if (trimmed.isEmpty()) return emptyList()
// A typed base URL is used as typed. PROPFIND on it can return principal,
// home-set and collection in one response, so DNS is never consulted.
asBaseUrl(trimmed)?.let { return listOf(Candidate(it, "base URL as typed")) }
// A typed URL is taken as typed and DNS is never consulted — a user who
// gave us an address meant it.
asBaseUrl(trimmed)?.let { typed ->
val candidates = mutableListOf<Candidate>()
// ⚠️ A typed URL is not automatically a *DAV* URL, and the bare
// origin is what people actually type. `https://cloud.example.com`
// is the web UI: PROPFIND on it returns the 405 any web server
// answers, which reads as "not a CalDAV server" about a perfectly
// good one. RFC 6764 §6 exists precisely for this case, so the
// well-known probe has to run here too — returning the typed URL as
// the only candidate is what made a working Nextcloud undiscoverable.
//
// ⚠️ Built through `newBuilder`, not by interpolating `host`, which
// returns an IPv6 literal *without* its brackets — "fd00::1", not
// "[fd00::1]". Pasting that back into a URL yields a string OkHttp
// will not parse, so both candidates would be dropped and a typed
// IPv6 address would report as "not an address". `toString()` also
// drops a default port on its own.
val origin = typed.newBuilder()
.encodedPath("/")
.query(null)
.fragment(null)
.build()
.toString()
if (typed.encodedPath.trim('/').isEmpty()) {
add(candidates, origin, WELL_KNOWN, "typed origin + .well-known")
add(candidates, origin, "/", "typed origin + root")
} else {
// A deep URL may well be the DAV root itself, and PROPFIND on it
// can return principal, home-set and collection in one response.
// The well-known stays as the fallback for a path that was a
// guess.
candidates += Candidate(typed, "base URL as typed")
add(candidates, origin, WELL_KNOWN, "typed host + .well-known")
}
return candidates
}
val domain = domainOf(trimmed) ?: return emptyList()
val candidates = mutableListOf<Candidate>()
@@ -191,7 +191,10 @@ class CalDavDiscoveryTest {
// answer "not authorised" — which the user reads as a wrong password.
val outcome = discovery.discover("http://cloud.example.com/dav/")
assertThat(outcome).isInstanceOf(CalDavDiscovery.Outcome.Failed::class.java)
assertThat((outcome as CalDavDiscovery.Outcome.Failed).reason).contains("http://")
val failed = outcome as CalDavDiscovery.Outcome.Failed
// The cause is what the UI renders; the detail is for logs only.
assertThat(failed.cause).isEqualTo(CalDavDiscovery.Outcome.Cause.INSECURE)
assertThat(failed.detail).contains("http://")
}
@Test
@@ -0,0 +1,42 @@
package de.jeanlucmakiola.caldav
import com.google.common.truth.Truth.assertThat
import okhttp3.HttpUrl.Companion.toHttpUrl
import org.junit.Test
class CalDavProviderTest {
@Test
fun `a hosted service is known by its host`() {
assertThat(CalDavProvider.forInput("me@fastmail.com")).isEqualTo(CalDavProvider.FASTMAIL)
assertThat(CalDavProvider.forHost("caldav.icloud.com")).isEqualTo(CalDavProvider.ICLOUD)
assertThat(CalDavProvider.forHost("dav.mailbox.org")).isEqualTo(CalDavProvider.MAILBOX_ORG)
assertThat(CalDavProvider.forHost("cloud.example.de")).isNull()
}
@Test
fun `self-hosted software is known by its principal path`() {
val nextcloud = "https://cloud.example.de/remote.php/dav/principals/users/jo/".toHttpUrl()
assertThat(CalDavProvider.forPrincipal(nextcloud)).isEqualTo(CalDavProvider.NEXTCLOUD)
val baikal = "https://dav.example.de/dav.php/principals/jo/".toHttpUrl()
assertThat(CalDavProvider.forPrincipal(baikal)).isEqualTo(CalDavProvider.BAIKAL)
val unknown = "https://dav.example.de/jo/".toHttpUrl()
assertThat(CalDavProvider.forPrincipal(unknown)).isNull()
}
@Test
fun `the host wins over the path`() {
// Fastmail's principals sit under a path we know nothing about; the host
// already named the service, so nothing else is consulted.
val url = "https://caldav.fastmail.com/dav/principals/user/me@fastmail.com/".toHttpUrl()
assertThat(CalDavProvider.forPrincipal(url)).isEqualTo(CalDavProvider.FASTMAIL)
}
@Test
fun `hosted services are titled by brand, self-hosted ones by host`() {
assertThat(CalDavProvider.FASTMAIL.hosted).isTrue()
assertThat(CalDavProvider.NEXTCLOUD.hosted).isFalse()
}
}
@@ -166,4 +166,42 @@ class NextcloudLoginFlowTest {
assertThat(mismatch!!.actual).isEqualTo("cloud.example.com")
assertThat(mismatch.message).contains("overwrite.cli.url")
}
@Test fun `a downgraded server URL in the poll response is coerced, not refused`() {
// ⚠️ The credential has already been issued and the 200 comes exactly
// once — the row is deleted inside poll() before it answers. Throwing
// here destroys a live app password and leaves it dangling in the user's
// device list, for no protection at all.
val flow = NextcloudLoginFlow(OkHttpClient(), "test")
val expected = "https://cloud.example.com/login/v2/poll".toHttpUrl()
val downgraded = "http://cloud.example.com".toHttpUrl()
val coerced = flow.secureOrigin(expected, downgraded)
assertThat(coerced.scheme).isEqualTo("https")
assertThat(coerced.host).isEqualTo("cloud.example.com")
}
@Test fun `an already-secure server URL is left alone`() {
val flow = NextcloudLoginFlow(OkHttpClient(), "test")
val expected = "https://cloud.example.com/login/v2/poll".toHttpUrl()
val actual = "https://dav.example.com".toHttpUrl()
// A different host is reported by hostMismatchOf, never rewritten here.
assertThat(flow.secureOrigin(expected, actual)).isEqualTo(actual)
}
@Test fun `the login URL is still refused outright when downgraded`() {
// Before approval there is nothing to lose by refusing, and the login URL
// is where the *account* password gets typed.
val flow = NextcloudLoginFlow(OkHttpClient(), "test")
val failure = runCatching {
flow.requireSecureOrigin(
"https://cloud.example.com".toHttpUrl(),
"http://cloud.example.com/login".toHttpUrl(),
)
}.exceptionOrNull()
assertThat(failure).isNotNull()
}
}
@@ -24,8 +24,19 @@ class ServiceDiscoveryTest {
@Test
fun `a typed base URL is used as typed and never triggers DNS`() {
val dns = FakeDns(srv = mapOf("_caldavs._tcp.example.com" to listOf(SrvRecord(0, 0, 8443, "dav.example.com"))))
assertThat(urls("https://cloud.example.com/remote.php/dav/", dns))
.containsExactly("https://cloud.example.com/remote.php/dav/")
val candidates = urls("https://cloud.example.com/remote.php/dav/", dns)
// The typed path is tried first and the SRV target is never consulted:
// a user who gave us an address meant it.
assertThat(candidates.first()).isEqualTo("https://cloud.example.com/remote.php/dav/")
assertThat(candidates).doesNotContain("https://dav.example.com:8443/")
// The well-known follows as a fallback, because a typed path may have
// been a guess — see `a typed deep URL is tried as typed, first`.
assertThat(candidates)
.containsExactly(
"https://cloud.example.com/remote.php/dav/",
"https://cloud.example.com/.well-known/caldav",
).inOrder()
}
@Test
@@ -125,4 +136,59 @@ class ServiceDiscoveryTest {
assertThat(ServiceDiscovery.candidatesFor("me@gmx.net", dns).map { it.origin })
.contains("domain as typed + TXT path=/begenda/dav/users/")
}
// --- a typed URL still gets the RFC 6764 probe -------------------------
@Test fun `a typed bare origin still tries well-known`() {
// ⚠️ The case every user actually types. The origin is the *web UI*, and
// PROPFIND on it returns the 405 any web server answers — which reads as
// "not a CalDAV server" about a working Nextcloud. Found against a real
// server, not by reading.
val paths = ServiceDiscovery.candidatesFor("https://cloud.example.com")
.map { it.url.encodedPath }
assertThat(paths).containsExactly("/.well-known/caldav", "/").inOrder()
}
@Test fun `a trailing slash is still a bare origin`() {
val paths = ServiceDiscovery.candidatesFor("https://cloud.example.com/")
.map { it.url.encodedPath }
assertThat(paths).containsExactly("/.well-known/caldav", "/").inOrder()
}
@Test fun `a typed deep URL is tried as typed, first`() {
// A deep URL may be the DAV root itself, where one PROPFIND can return
// principal, home-set and collection together.
val paths = ServiceDiscovery.candidatesFor("https://cloud.example.com/remote.php/dav/")
.map { it.url.encodedPath }
assertThat(paths.first()).isEqualTo("/remote.php/dav/")
// …but the path may have been a guess, so the probe stays as a fallback.
assertThat(paths).contains("/.well-known/caldav")
}
@Test fun `a non-default port survives the origin rebuild`() {
val candidates = ServiceDiscovery.candidatesFor("https://cloud.example.com:8443")
assertThat(candidates.map { it.url.toString() })
.containsExactly(
"https://cloud.example.com:8443/.well-known/caldav",
"https://cloud.example.com:8443/",
).inOrder()
}
@Test fun `an IPv6 literal keeps its brackets through the origin rebuild`() {
// ⚠️ `HttpUrl.host` hands back "fd00::1", not "[fd00::1]", so an origin
// built by interpolating it is a string OkHttp will not parse — both
// candidates were silently dropped and a homelab address reported as
// "not an address".
val candidates = ServiceDiscovery.candidatesFor("https://[fd00::1]:8443")
assertThat(candidates.map { it.url.toString() })
.containsExactly(
"https://[fd00::1]:8443/.well-known/caldav",
"https://[fd00::1]:8443/",
).inOrder()
}
}
+25
View File
@@ -155,3 +155,28 @@ exists in this version:
Fetch the new tag, diff against `f434c9d`, reapply changes 1–4, run
`./gradlew :dav:test`. If upstream's suite fails, the port is wrong — that is the
entire reason it is vendored alongside the code.
## Change 7 — a same-host HTTPS→HTTP redirect is upgraded, not refused
`DavResource.followRedirects` threw `DavException("Received redirect from HTTPS
to HTTP")` for any downgrade. That is right for a redirect to a *different* host,
which has no innocent reading. It is wrong for the same host, and the same host
is the case that actually occurs.
⚠️ **A Nextcloud behind a TLS-terminating reverse proxy without
`overwriteprotocol` — or without `proxy_set_header X-Forwarded-Proto $scheme` —
builds every redirect with `http://`.** That includes the `/.well-known/caldav`
hop RFC 6764 discovery depends on. The server is entirely functional:
`/remote.php/dav/` answers 401 over HTTPS exactly as it should. But discovery
refuses the downgrade, falls back to a `PROPFIND` on the web root, gets the 405
an ordinary web server returns, and reports "not a CalDAV server" about a working
CalDAV server.
Now: when the redirect target's host matches the current one, the scheme is put
back to `https` and the hop continues. Re-issuing the same host and path over TLS
is *strictly safer* than obeying the redirect as sent, and it preserves the
invariant that matters — credentials never travel in cleartext. A cross-host
downgrade still throws.
Found against a real server, not by reading: `cloud.jeanlucmakiola.de` returns
`301 → http://cloud.jeanlucmakiola.de/remote.php/dav/`.
+1
View File
@@ -33,4 +33,5 @@ dependencies {
// vendoring safe, which is the same call provider/PROVENANCE.md made.
testImplementation(libs.junit4)
testImplementation(libs.okhttp.mockwebserver)
testImplementation(libs.okhttp.tls)
}
@@ -635,12 +635,34 @@ open class DavResource @JvmOverloads constructor(
if (response.isRedirect)
// handle 3xx Redirection
response.use {
val target = it.header("Location")?.let { location.resolve(it) }
var target = it.header("Location")?.let { location.resolve(it) }
if (target != null) {
log.fine("Redirected, new location = $target")
if (location.isHttps && !target.isHttps)
throw DavException("Received redirect from HTTPS to HTTP")
if (location.isHttps && !target.isHttps) {
// ⚠️ A downgrade to the *same host* is a server
// misconfiguration, not an attack, and it is the
// single most common one in this space: a Nextcloud
// behind a TLS-terminating proxy without
// `overwriteprotocol` (or `X-Forwarded-Proto`) builds
// every redirect with http://, including the
// /.well-known/caldav hop that discovery depends on.
// Refusing it outright makes a perfectly good server
// undiscoverable, and the user cannot tell why.
//
// Re-issuing the same host and path over TLS is
// strictly safer than what we were asked to do, and
// preserves the invariant that actually matters:
// credentials never travel in the clear. A downgrade
// pointing at a *different* host has no innocent
// reading, so that still fails.
if (target.host == location.host) {
target = target.newBuilder().scheme("https").build()
log.fine("Upgraded same-host downgrade back to $target")
} else {
throw DavException("Received redirect from HTTPS to HTTP")
}
}
if (chainStillPermanent && (it.code == HTTP_MOVED_PERM || it.code == HTTP_PERM_REDIRECT))
permanentLocation = target
@@ -10,10 +10,13 @@ import at.bitfire.dav4jvm.property.CurrentUserPrincipal
import okhttp3.OkHttpClient
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import okhttp3.tls.HandshakeCertificates
import okhttp3.tls.HeldCertificate
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertThrows
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
@@ -242,4 +245,73 @@ class LocalChangesTest {
TimeZone.setDefault(zone)
}
}
// --- change 7: a same-host downgrade is upgraded, a cross-host one is not ---
/**
* A TLS MockWebServer, because the branch under test only exists on HTTPS.
*
* Same trap as change 2's: a test that runs against plain HTTP here would
* pass whatever the code did, since `location.isHttps` gates the whole
* decision.
*/
private fun httpsServer(): Pair<MockWebServer, OkHttpClient> {
val certificate = HeldCertificate.Builder()
.addSubjectAlternativeName("localhost")
.build()
val serverCertificates = HandshakeCertificates.Builder()
.heldCertificate(certificate)
.build()
val clientCertificates = HandshakeCertificates.Builder()
.addTrustedCertificate(certificate.certificate)
.build()
val tlsServer = MockWebServer().apply {
useHttps(serverCertificates.sslSocketFactory(), false)
start()
}
val client = OkHttpClient.Builder()
.followRedirects(false)
.sslSocketFactory(
clientCertificates.sslSocketFactory(),
clientCertificates.trustManager,
)
.build()
return tlsServer to client
}
@Test
fun `a same-host redirect to http is retried over https`() {
// ⚠️ Exactly what a Nextcloud behind a proxy without `overwriteprotocol`
// sends for /.well-known/caldav. Refusing it makes a working CalDAV
// server report as "not a CalDAV server".
val (tls, client) = httpsServer()
val resource = DavResource(client, tls.url("/.well-known/caldav"))
val downgrade = "http://${tls.hostName}:${tls.port}/remote.php/dav/"
tls.enqueue(MockResponse().setResponseCode(301).setHeader("Location", downgrade))
tls.enqueue(MockResponse().setResponseCode(200))
resource.head { }
tls.takeRequest()
val followed = tls.takeRequest()
assertEquals("/remote.php/dav/", followed.path)
// The scheme was put back; the host and path are the server's own.
assertEquals("https", resource.location.scheme)
assertEquals("/remote.php/dav/", resource.location.encodedPath)
}
@Test
fun `a cross-host redirect to http is still refused`() {
val (tls, client) = httpsServer()
val resource = DavResource(client, tls.url("/dav/"))
tls.enqueue(
MockResponse().setResponseCode(301)
.setHeader("Location", "http://elsewhere.example.com/dav/"),
)
// No innocent reading of this one, so it stays fatal.
assertThrows(at.bitfire.dav4jvm.exception.DavException::class.java) { resource.head { } }
}
}
+22
View File
@@ -854,6 +854,28 @@ would have quarantined every Lightning-authored task permanently.
was re-requested for ever with nothing counting it, and account removal blocked
on a 30-second connect timeout before anything visible happened.
## Noted for later, not built
**Sign in via the installed Nextcloud app.** If the user already has Nextcloud
Files on the device, they should be able to pick the account they are *already
signed into* rather than typing a server address and minting a password by hand
— which is the longest and most failure-prone part of the flow, and the one that
produces the 405s and "wrong password" dead ends this branch keeps working
around.
Nextcloud's Android app exposes its accounts through the **Single Sign-On**
library (`nextcloud/Android-SingleSignOn`): an AIDL binding to the Files app,
which returns a per-app token after the user picks an account and approves. It
would slot in beside Login Flow v2 as a third path on the address step —
offered only when the Files app is actually installed, with the typed-address
flow unchanged as the fallback for everyone else.
Deliberately not in this branch. It adds a GPL-licensed dependency and a second
credential *shape* (an SSO token that the Files app can revoke independently of
anything we store), and both deserve their own decision rather than being
absorbed into a chunk. Worth doing: it is the difference between "type your
server address" and "tap your account".
## What moves to floret-kit
Recorded here so the follow-up branch is a file move rather than a rediscovery.
+2
View File
@@ -156,6 +156,8 @@ androidx-test-rules = { group = "androidx.test", name = "rules", version.ref = "
# :dav — vendored dav4jvm (see dav/PROVENANCE.md)
okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }
okhttp-mockwebserver = { group = "com.squareup.okhttp3", name = "mockwebserver", version.ref = "okhttp" }
# Test-only: MockWebServer over TLS, so an HTTPS-only code path can be exercised.
okhttp-tls = { group = "com.squareup.okhttp3", name = "okhttp-tls", version.ref = "okhttp" }
xpp3 = { group = "org.ogce", name = "xpp3", version.ref = "xpp3" }
# :caldav — RFC 6764 service discovery