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:
@@ -69,3 +69,7 @@ Thumbs.db
|
|||||||
# Local agent notes: machine-specific build setup and on-device rules, not
|
# Local agent notes: machine-specific build setup and on-device rules, not
|
||||||
# anything the project itself depends on.
|
# anything the project itself depends on.
|
||||||
/CLAUDE.md
|
/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.CalDavDiscovery
|
||||||
import de.jeanlucmakiola.caldav.TaskCollection
|
import de.jeanlucmakiola.caldav.TaskCollection
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import kotlinx.coroutines.withTimeoutOrNull
|
import kotlinx.coroutines.withTimeoutOrNull
|
||||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||||
@@ -62,6 +63,9 @@ class AccountRepository @Inject constructor(
|
|||||||
|
|
||||||
suspend fun all(): List<AccountEntity> = withContext(io) { database.accounts().all() }
|
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.
|
* 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.edit
|
||||||
import androidx.datastore.preferences.core.stringSetPreferencesKey
|
import androidx.datastore.preferences.core.stringSetPreferencesKey
|
||||||
import de.jeanlucmakiola.agendula.data.di.SyncStateDataStore
|
import de.jeanlucmakiola.agendula.data.di.SyncStateDataStore
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
@@ -28,8 +30,12 @@ class AccountStateStore @Inject constructor(
|
|||||||
@SyncStateDataStore private val dataStore: DataStore<Preferences>,
|
@SyncStateDataStore private val dataStore: DataStore<Preferences>,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
suspend fun needingSignIn(): Set<Long> =
|
suspend fun needingSignIn(): Set<Long> = observeNeedingSignIn().first()
|
||||||
dataStore.data.first()[KEY].orEmpty().mapNotNull { it.toLongOrNull() }.toSet()
|
|
||||||
|
/** Observed, so a 401 during a background sync reaches an open screen. */
|
||||||
|
fun observeNeedingSignIn(): Flow<Set<Long>> = dataStore.data.map { prefs ->
|
||||||
|
prefs[KEY].orEmpty().mapNotNull { it.toLongOrNull() }.toSet()
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun needsSignIn(accountId: Long): Boolean = accountId in needingSignIn()
|
suspend fun needsSignIn(accountId: Long): Boolean = accountId in needingSignIn()
|
||||||
|
|
||||||
|
|||||||
@@ -4,15 +4,27 @@ import androidx.room.Dao
|
|||||||
import androidx.room.Insert
|
import androidx.room.Insert
|
||||||
import androidx.room.Query
|
import androidx.room.Query
|
||||||
import androidx.room.Update
|
import androidx.room.Update
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
|
|
||||||
/** Reads and writes over `accounts`. Unused until sync lands. */
|
/** Reads and writes over `accounts`. */
|
||||||
@Dao
|
@Dao
|
||||||
interface AccountDao {
|
interface AccountDao {
|
||||||
|
|
||||||
@Query("SELECT * FROM accounts ORDER BY display_name")
|
@Query("SELECT * FROM accounts ORDER BY display_name")
|
||||||
fun all(): List<AccountEntity>
|
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")
|
@Query("SELECT * FROM accounts WHERE id = :accountId")
|
||||||
fun account(accountId: Long): AccountEntity?
|
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 android.text.format.DateUtils
|
||||||
import androidx.compose.foundation.layout.Spacer
|
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.height
|
||||||
import androidx.compose.foundation.layout.padding
|
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.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.rounded.Login
|
||||||
import androidx.compose.material.icons.rounded.Add
|
import androidx.compose.material.icons.rounded.Add
|
||||||
import androidx.compose.material.icons.rounded.CloudSync
|
import androidx.compose.material.icons.rounded.CloudSync
|
||||||
import androidx.compose.material.icons.rounded.Login
|
|
||||||
import androidx.compose.material.icons.rounded.Sync
|
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.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
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.Modifier
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.semantics.Role
|
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import de.jeanlucmakiola.agendula.R
|
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.CollapsingScaffold
|
||||||
|
import de.jeanlucmakiola.floret.components.GroupedListInset
|
||||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||||
import de.jeanlucmakiola.floret.components.Position
|
import de.jeanlucmakiola.floret.components.Position
|
||||||
import de.jeanlucmakiola.floret.components.positionOf
|
import de.jeanlucmakiola.floret.components.positionOf
|
||||||
@@ -43,15 +30,11 @@ import de.jeanlucmakiola.floret.components.positionOf
|
|||||||
@Composable
|
@Composable
|
||||||
internal fun AccountsScreen(
|
internal fun AccountsScreen(
|
||||||
onAddAccount: () -> Unit,
|
onAddAccount: () -> Unit,
|
||||||
|
onOpenAccount: (Long) -> Unit,
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
viewModel: AccountsViewModel,
|
viewModel: AccountsViewModel,
|
||||||
) {
|
) {
|
||||||
val accounts by viewModel.accounts.collectAsStateWithLifecycle()
|
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(
|
CollapsingScaffold(
|
||||||
title = stringResource(R.string.settings_section_accounts),
|
title = stringResource(R.string.settings_section_accounts),
|
||||||
@@ -63,50 +46,36 @@ internal fun AccountsScreen(
|
|||||||
Icon(
|
Icon(
|
||||||
Icons.Rounded.CloudSync,
|
Icons.Rounded.CloudSync,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
modifier = Modifier.padding(horizontal = 16.dp),
|
modifier = Modifier.padding(horizontal = GroupedListInset),
|
||||||
)
|
)
|
||||||
Spacer(Modifier.height(12.dp))
|
Spacer(Modifier.height(12.dp))
|
||||||
Text(
|
Text(
|
||||||
stringResource(R.string.accounts_empty_title),
|
stringResource(R.string.accounts_empty_title),
|
||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
modifier = Modifier.padding(horizontal = 16.dp),
|
modifier = Modifier.padding(horizontal = GroupedListInset),
|
||||||
)
|
)
|
||||||
Spacer(Modifier.height(4.dp))
|
Spacer(Modifier.height(4.dp))
|
||||||
Text(
|
Text(
|
||||||
stringResource(R.string.accounts_empty_body),
|
stringResource(R.string.accounts_empty_body),
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
modifier = Modifier.padding(horizontal = 16.dp),
|
modifier = Modifier.padding(horizontal = GroupedListInset),
|
||||||
)
|
)
|
||||||
Spacer(Modifier.height(24.dp))
|
Spacer(Modifier.height(24.dp))
|
||||||
} else {
|
} else {
|
||||||
loaded.forEachIndexed { index, row ->
|
loaded.forEachIndexed { index, row ->
|
||||||
val account = row.account
|
val account = row.account
|
||||||
|
val identity = account.identity()
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
title = account.displayName,
|
title = identity.title,
|
||||||
// Never the raw values: lastSyncError is an exception string
|
summary = accountSummary(identity.user, syncState(row)),
|
||||||
// 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)
|
|
||||||
},
|
|
||||||
position = positionOf(index, loaded.size),
|
position = positionOf(index, loaded.size),
|
||||||
modifier = Modifier.padding(horizontal = 16.dp),
|
leading = { ProviderLogo(identity.provider) },
|
||||||
trailing = {
|
trailing = {
|
||||||
if (row.needsSignIn) {
|
if (row.needsSignIn) {
|
||||||
IconButton(onClick = onAddAccount) {
|
IconButton(onClick = onAddAccount) {
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Rounded.Login,
|
Icons.AutoMirrored.Rounded.Login,
|
||||||
contentDescription = stringResource(R.string.accounts_sign_in_again),
|
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))
|
Spacer(Modifier.height(24.dp))
|
||||||
@@ -128,53 +97,32 @@ internal fun AccountsScreen(
|
|||||||
GroupedRow(
|
GroupedRow(
|
||||||
title = stringResource(R.string.accounts_add),
|
title = stringResource(R.string.accounts_add),
|
||||||
position = Position.Alone,
|
position = Position.Alone,
|
||||||
modifier = Modifier.padding(horizontal = 16.dp),
|
|
||||||
leading = { Icon(Icons.Rounded.Add, contentDescription = null) },
|
leading = { Icon(Icons.Rounded.Add, contentDescription = null) },
|
||||||
onClick = onAddAccount,
|
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.AccountStateStore
|
||||||
import de.jeanlucmakiola.agendula.data.sync.SyncTrigger
|
import de.jeanlucmakiola.agendula.data.sync.SyncTrigger
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.room.AccountEntity
|
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.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import javax.inject.Inject
|
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. */
|
/** An account row, plus the one thing the row cannot read from the entity. */
|
||||||
data class AccountRow(val account: AccountEntity, val needsSignIn: Boolean)
|
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.
|
||||||
/** `null` until the first load, so the empty state does not flash. */
|
*
|
||||||
val accounts: StateFlow<List<AccountRow>?> = _accounts.asStateFlow()
|
* ⚠️ Observed, not fetched. The sync that a tap on this screen starts
|
||||||
|
* finishes on a background thread some seconds later, and a snapshot taken
|
||||||
init {
|
* when the screen opened cannot show it — the row went on saying "never
|
||||||
refresh()
|
* 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.
|
||||||
|
*/
|
||||||
fun refresh() {
|
val accounts: StateFlow<List<AccountRow>?> =
|
||||||
viewModelScope.launch {
|
combine(
|
||||||
val stopped = accountState.needingSignIn()
|
repository.observeAll(),
|
||||||
_accounts.value = repository.all().map { AccountRow(it, it.id in stopped) }
|
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.
|
* The app's own sync trigger.
|
||||||
@@ -54,9 +60,13 @@ class AccountsViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun remove(account: AccountEntity, deleteLocalData: Boolean) {
|
fun remove(account: AccountEntity, deleteLocalData: Boolean) {
|
||||||
viewModelScope.launch {
|
// No refresh: the row disappears because the query behind `accounts`
|
||||||
repository.remove(account.id, account.displayName, deleteLocalData)
|
// re-emits.
|
||||||
refresh()
|
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 android.content.Intent
|
||||||
import androidx.browser.customtabs.CustomTabsIntent
|
import androidx.browser.customtabs.CustomTabsIntent
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.ColumnScope
|
||||||
import androidx.compose.foundation.layout.Spacer
|
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.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
import androidx.compose.material.icons.Icons
|
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.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.Button
|
||||||
import androidx.compose.material3.Checkbox
|
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.OutlinedTextField
|
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
@@ -26,21 +34,31 @@ import androidx.compose.runtime.LaunchedEffect
|
|||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.stringResource
|
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.ImeAction
|
||||||
|
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||||
import androidx.compose.ui.text.input.KeyboardType
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||||
|
import androidx.compose.ui.text.input.VisualTransformation
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.core.net.toUri
|
import androidx.core.net.toUri
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import de.jeanlucmakiola.agendula.R
|
import de.jeanlucmakiola.agendula.R
|
||||||
|
import de.jeanlucmakiola.caldav.CalDavDiscovery
|
||||||
import de.jeanlucmakiola.caldav.ServerQuirk
|
import de.jeanlucmakiola.caldav.ServerQuirk
|
||||||
import de.jeanlucmakiola.floret.components.CollapsingScaffold
|
import de.jeanlucmakiola.floret.components.CollapsingScaffold
|
||||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||||
import de.jeanlucmakiola.floret.components.GroupedSurface
|
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.Position
|
||||||
|
import de.jeanlucmakiola.floret.components.SelectedCheck
|
||||||
import de.jeanlucmakiola.floret.components.positionOf
|
import de.jeanlucmakiola.floret.components.positionOf
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -86,26 +104,56 @@ internal fun AddAccountScreen(
|
|||||||
viewModel.onBrowserLaunched()
|
viewModel.onBrowserLaunched()
|
||||||
}
|
}
|
||||||
|
|
||||||
CollapsingScaffold(
|
// Backing out abandons a flow that may still be polling the server every two
|
||||||
title = stringResource(R.string.add_account_title),
|
// seconds for the rest of its twenty-minute window.
|
||||||
// Backing out abandons a flow that may still be polling the server every
|
val abandon = {
|
||||||
// two seconds for the rest of its twenty-minute window.
|
viewModel.onStartOver()
|
||||||
onBack = {
|
onBack()
|
||||||
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
|
val fatal = state.fatal
|
||||||
if (fatal != null) {
|
if (fatal != null) {
|
||||||
Message(icon = { Icon(Icons.Rounded.CloudOff, contentDescription = null) }, text = fatal)
|
Text(
|
||||||
Spacer(Modifier.height(16.dp))
|
fatal,
|
||||||
Button(
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
onClick = viewModel::onStartOver,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
modifier = Modifier.padding(horizontal = 16.dp),
|
textAlign = TextAlign.Center,
|
||||||
) { Text(stringResource(R.string.add_account_start_over)) }
|
modifier = Modifier.padding(horizontal = 24.dp),
|
||||||
return@CollapsingScaffold
|
)
|
||||||
|
return@OnboardingScaffold
|
||||||
}
|
}
|
||||||
|
|
||||||
|
StepTitle(state.step)
|
||||||
|
|
||||||
when (val step = state.step) {
|
when (val step = state.step) {
|
||||||
is AddAccountStep.EnterServer -> ServerStep(step, state.quirk, viewModel)
|
is AddAccountStep.EnterServer -> ServerStep(step, state.quirk, viewModel)
|
||||||
is AddAccountStep.Working -> WorkingStep(step)
|
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
|
@Composable
|
||||||
private fun ServerStep(
|
private fun ServerStep(
|
||||||
step: AddAccountStep.EnterServer,
|
step: AddAccountStep.EnterServer,
|
||||||
@@ -124,69 +240,219 @@ private fun ServerStep(
|
|||||||
viewModel: AddAccountViewModel,
|
viewModel: AddAccountViewModel,
|
||||||
) {
|
) {
|
||||||
Column(Modifier.padding(horizontal = 16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
Column(Modifier.padding(horizontal = 16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||||
OutlinedTextField(
|
FieldRow(
|
||||||
|
label = stringResource(R.string.add_account_server_label),
|
||||||
value = step.input,
|
value = step.input,
|
||||||
onValueChange = viewModel::onServerInputChanged,
|
onValueChange = viewModel::onServerInputChanged,
|
||||||
label = { Text(stringResource(R.string.add_account_server_label)) },
|
placeholder = stringResource(R.string.add_account_server_hint),
|
||||||
placeholder = { Text(stringResource(R.string.add_account_server_hint)) },
|
error = step.error?.let { stringResource(it.message) },
|
||||||
isError = step.error != null,
|
keyboardType = KeyboardType.Uri,
|
||||||
supportingText = step.error?.let { { Text(it) } },
|
onImeAction = viewModel::onServerSubmitted,
|
||||||
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.
|
// Named before the attempt, not after a 401 the user cannot act on.
|
||||||
quirk?.let { QuirkNote(it) }
|
quirk?.let { QuirkNote(it) }
|
||||||
|
|
||||||
Button(
|
|
||||||
onClick = viewModel::onServerSubmitted,
|
|
||||||
enabled = step.input.isNotBlank(),
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
) { Text(stringResource(R.string.add_account_continue)) }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun CredentialsStep(step: AddAccountStep.EnterCredentials, viewModel: AddAccountViewModel) {
|
private fun CredentialsStep(step: AddAccountStep.EnterCredentials, viewModel: AddAccountViewModel) {
|
||||||
Column(Modifier.padding(horizontal = 16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
Column(Modifier.padding(horizontal = 16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||||
OutlinedTextField(
|
// Two fields in one connected group, which is what the family does with
|
||||||
value = step.username,
|
// a pair that is filled in together.
|
||||||
onValueChange = viewModel::onUsernameChanged,
|
Column {
|
||||||
label = { Text(stringResource(R.string.add_account_username_label)) },
|
FieldRow(
|
||||||
singleLine = true,
|
label = stringResource(R.string.add_account_username_label),
|
||||||
modifier = Modifier.fillMaxWidth(),
|
value = step.username,
|
||||||
)
|
onValueChange = viewModel::onUsernameChanged,
|
||||||
OutlinedTextField(
|
position = Position.Top,
|
||||||
value = step.password,
|
)
|
||||||
onValueChange = viewModel::onPasswordChanged,
|
FieldRow(
|
||||||
label = { Text(stringResource(R.string.add_account_password_label)) },
|
label = stringResource(R.string.add_account_password_label),
|
||||||
visualTransformation = PasswordVisualTransformation(),
|
value = step.password,
|
||||||
isError = step.error != null,
|
onValueChange = viewModel::onPasswordChanged,
|
||||||
supportingText = {
|
position = Position.Bottom,
|
||||||
Text(step.error ?: stringResource(R.string.add_account_password_hint))
|
error = step.error,
|
||||||
},
|
hint = stringResource(R.string.add_account_password_hint),
|
||||||
singleLine = true,
|
|
||||||
keyboardOptions = KeyboardOptions(
|
|
||||||
keyboardType = KeyboardType.Password,
|
keyboardType = KeyboardType.Password,
|
||||||
imeAction = ImeAction.Go,
|
onImeAction = viewModel::onCredentialsSubmitted,
|
||||||
),
|
)
|
||||||
modifier = Modifier.fillMaxWidth(),
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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(
|
return
|
||||||
onClick = viewModel::onCredentialsSubmitted,
|
}
|
||||||
|
|
||||||
|
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(),
|
enabled = step.username.isNotBlank() && step.password.isNotEmpty(),
|
||||||
modifier = Modifier.fillMaxWidth(),
|
onClick = viewModel::onCredentialsSubmitted,
|
||||||
) { Text(stringResource(R.string.add_account_sign_in)) }
|
)
|
||||||
|
|
||||||
|
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
|
@Composable
|
||||||
private fun BrowserStep(step: AddAccountStep.WaitingForBrowser, viewModel: AddAccountViewModel) {
|
private fun BrowserStep(step: AddAccountStep.WaitingForBrowser, viewModel: AddAccountViewModel) {
|
||||||
Column(Modifier.padding(horizontal = 16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
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(
|
Message(
|
||||||
icon = {
|
icon = {
|
||||||
if (step.error == null) {
|
if (step.error == null) {
|
||||||
@@ -195,7 +461,11 @@ private fun BrowserStep(step: AddAccountStep.WaitingForBrowser, viewModel: AddAc
|
|||||||
Icon(Icons.Rounded.CloudOff, contentDescription = null)
|
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),
|
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
|
@Composable
|
||||||
private fun ListsStep(step: AddAccountStep.ChooseLists, viewModel: AddAccountViewModel) {
|
private fun ListsStep(step: AddAccountStep.ChooseLists, viewModel: AddAccountViewModel) {
|
||||||
Text(
|
// The heading is the shell's; this step only lists.
|
||||||
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 ->
|
step.collections.forEachIndexed { index, collection ->
|
||||||
val checked = collection.url in step.selected
|
val checked = collection.url in step.selected
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
@@ -246,27 +508,13 @@ private fun ListsStep(step: AddAccountStep.ChooseLists, viewModel: AddAccountVie
|
|||||||
},
|
},
|
||||||
position = positionOf(index, step.collections.size),
|
position = positionOf(index, step.collections.size),
|
||||||
selected = checked,
|
selected = checked,
|
||||||
modifier = Modifier.padding(horizontal = 16.dp),
|
// The family marks a chosen row with a check, not a Material
|
||||||
trailing = {
|
// checkbox — same affordance every picker in the app uses.
|
||||||
Checkbox(checked = checked, onCheckedChange = null)
|
trailing = { if (checked) SelectedCheck() },
|
||||||
},
|
|
||||||
onClick = { viewModel.onListToggled(collection.url) },
|
onClick = { viewModel.onListToggled(collection.url) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Spacer(Modifier.height(16.dp))
|
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
|
@Composable
|
||||||
|
|||||||
@@ -25,7 +25,11 @@ import javax.inject.Inject
|
|||||||
sealed interface AddAccountStep {
|
sealed interface AddAccountStep {
|
||||||
|
|
||||||
/** Type an address. The whole flow starts from one field. */
|
/** 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
|
data class Working(val message: String) : AddAccountStep
|
||||||
|
|
||||||
@@ -134,9 +138,9 @@ class AddAccountViewModel @Inject constructor(
|
|||||||
// would make it permanently unreachable.
|
// would make it permanently unreachable.
|
||||||
CalDavDiscovery.Outcome.Unauthenticated -> offerSignIn()
|
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.NotCalDav -> backToServer(outcome.cause)
|
||||||
is CalDavDiscovery.Outcome.Failed -> backToServer(outcome.reason)
|
is CalDavDiscovery.Outcome.Failed -> backToServer(outcome.cause)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Step 2b → the browser flow, if this looks like a Nextcloud. */
|
/** Step 2b → the browser flow, if this looks like a Nextcloud. */
|
||||||
private suspend fun offerSignIn() {
|
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)
|
val flow = gateway.startLoginFlow(root)
|
||||||
|
|
||||||
if (flow == null) {
|
if (flow == null) {
|
||||||
@@ -230,7 +234,7 @@ class AddAccountViewModel @Inject constructor(
|
|||||||
if (outcome is CalDavDiscovery.Outcome.Found) {
|
if (outcome is CalDavDiscovery.Outcome.Found) {
|
||||||
onDiscovered(outcome)
|
onDiscovered(outcome)
|
||||||
} else {
|
} else {
|
||||||
backToServer("Signed in, but no task lists could be read.")
|
backToServer(CalDavDiscovery.Outcome.Cause.NO_CALENDARS)
|
||||||
}
|
}
|
||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
@@ -371,8 +375,16 @@ class AddAccountViewModel @Inject constructor(
|
|||||||
it.copy(step = AddAccountStep.EnterServer(input = typedInput), fatal = reason)
|
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 {
|
private fun browserFailed(reason: String) = _state.update {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import androidx.compose.ui.res.stringResource
|
|||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import de.jeanlucmakiola.agendula.R
|
import de.jeanlucmakiola.agendula.R
|
||||||
import de.jeanlucmakiola.floret.components.CollapsingScaffold
|
import de.jeanlucmakiola.floret.components.CollapsingScaffold
|
||||||
|
import de.jeanlucmakiola.floret.components.GroupedListInset
|
||||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||||
import de.jeanlucmakiola.floret.components.positionOf
|
import de.jeanlucmakiola.floret.components.positionOf
|
||||||
|
|
||||||
@@ -35,7 +36,7 @@ internal fun LicencesScreen(onBack: () -> Unit) {
|
|||||||
stringResource(R.string.licences_intro),
|
stringResource(R.string.licences_intro),
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
modifier = Modifier.padding(horizontal = 16.dp),
|
modifier = Modifier.padding(horizontal = GroupedListInset),
|
||||||
)
|
)
|
||||||
Spacer(Modifier.height(16.dp))
|
Spacer(Modifier.height(16.dp))
|
||||||
|
|
||||||
@@ -44,7 +45,6 @@ internal fun LicencesScreen(onBack: () -> Unit) {
|
|||||||
title = attribution.name,
|
title = attribution.name,
|
||||||
summary = "${attribution.copyright} · ${attribution.licence.spdxId}",
|
summary = "${attribution.copyright} · ${attribution.licence.spdxId}",
|
||||||
position = positionOf(index, OpenSourceLicences.ALL.size),
|
position = positionOf(index, OpenSourceLicences.ALL.size),
|
||||||
modifier = Modifier.padding(horizontal = 16.dp),
|
|
||||||
onClick = { uriHandler.openUri(attribution.sourceUrl) },
|
onClick = { uriHandler.openUri(attribution.sourceUrl) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -54,7 +54,7 @@ internal fun LicencesScreen(onBack: () -> Unit) {
|
|||||||
stringResource(R.string.licences_footer),
|
stringResource(R.string.licences_footer),
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
modifier = Modifier.padding(horizontal = 16.dp),
|
modifier = Modifier.padding(horizontal = GroupedListInset),
|
||||||
)
|
)
|
||||||
Spacer(Modifier.height(24.dp))
|
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.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.AccountDetailScreen
|
||||||
import de.jeanlucmakiola.agendula.ui.accounts.AccountsScreen
|
import de.jeanlucmakiola.agendula.ui.accounts.AccountsScreen
|
||||||
import de.jeanlucmakiola.agendula.ui.accounts.AccountsViewModel
|
import de.jeanlucmakiola.agendula.ui.accounts.AccountsViewModel
|
||||||
import de.jeanlucmakiola.agendula.ui.accounts.AddAccountScreen
|
import de.jeanlucmakiola.agendula.ui.accounts.AddAccountScreen
|
||||||
@@ -113,14 +114,15 @@ private enum class SettingsSection {
|
|||||||
Export,
|
Export,
|
||||||
Accounts,
|
Accounts,
|
||||||
AddAccount,
|
AddAccount,
|
||||||
|
Account,
|
||||||
Licences,
|
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?
|
val parent: SettingsSection?
|
||||||
get() = when (this) {
|
get() = when (this) {
|
||||||
Export -> Storage
|
Export -> Storage
|
||||||
AddAccount -> Accounts
|
AddAccount, Account -> Accounts
|
||||||
else -> null
|
else -> null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -148,6 +150,8 @@ fun SettingsScreen(
|
|||||||
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.
|
// Hoisted so the add flow can refresh the list it returns to.
|
||||||
val accountsViewModel: AccountsViewModel = hiltViewModel()
|
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
|
// 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.
|
||||||
@@ -189,6 +193,10 @@ fun SettingsScreen(
|
|||||||
SlideInSection(visible = accountsOpen) {
|
SlideInSection(visible = accountsOpen) {
|
||||||
AccountsScreen(
|
AccountsScreen(
|
||||||
onAddAccount = { section = SettingsSection.AddAccount },
|
onAddAccount = { section = SettingsSection.AddAccount },
|
||||||
|
onOpenAccount = {
|
||||||
|
openAccount = it
|
||||||
|
section = SettingsSection.Account
|
||||||
|
},
|
||||||
onBack = { section = null },
|
onBack = { section = null },
|
||||||
viewModel = accountsViewModel,
|
viewModel = accountsViewModel,
|
||||||
)
|
)
|
||||||
@@ -196,15 +204,24 @@ fun SettingsScreen(
|
|||||||
SlideInSection(visible = section == SettingsSection.Licences) {
|
SlideInSection(visible = section == SettingsSection.Licences) {
|
||||||
LicencesScreen(onBack = { section = null })
|
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) {
|
SlideInSection(visible = section == SettingsSection.AddAccount) {
|
||||||
AddAccountScreen(
|
AddAccountScreen(
|
||||||
onDone = {
|
// The list stays composed underneath, so nothing re-runs its
|
||||||
// The list stays composed underneath, so nothing re-runs its
|
// init and OnResume never fires on a section change. It no longer
|
||||||
// init and OnResume never fires on a section change — without
|
// needs to: the accounts come from an observed query, so a new
|
||||||
// this the user returns to the empty state they just left.
|
// account — and every later sync — arrives on its own.
|
||||||
accountsViewModel.refresh()
|
onDone = { section = SettingsSection.Accounts },
|
||||||
section = SettingsSection.Accounts
|
|
||||||
},
|
|
||||||
onBack = { 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>
|
||||||
@@ -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_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_add">Add an account</string>
|
||||||
<string name="accounts_remove">Remove account</string>
|
<string name="accounts_remove">Remove account</string>
|
||||||
<string name="accounts_remove_confirm_title">Remove %1$s?</string>
|
<string name="accounts_remove_body">%1$s stops syncing with this device. Nothing is deleted from the server.</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_keep">Remove, keep the tasks</string>
|
||||||
<string name="accounts_remove_confirm_action">Remove</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_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_failed">Last sync didn\u2019t finish</string>
|
||||||
<string name="accounts_sync_now">Sync now</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_needs_sign_in">Sign in again to keep syncing</string>
|
||||||
<string name="accounts_sign_in_again">Sign in again</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_channel">Syncing</string>
|
||||||
<string name="sync_notification_title">Syncing tasks</string>
|
<string name="sync_notification_title">Syncing tasks</string>
|
||||||
|
|
||||||
|
|||||||
+11
-3
@@ -85,7 +85,10 @@ class AddAccountViewModelTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `a server that is not CalDAV says so on the address step`() = runTest(dispatcher) {
|
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()
|
val vm = viewModel()
|
||||||
vm.onServerInputChanged("https://example.com/")
|
vm.onServerInputChanged("https://example.com/")
|
||||||
@@ -93,7 +96,9 @@ class AddAccountViewModelTest {
|
|||||||
advanceUntilIdle()
|
advanceUntilIdle()
|
||||||
|
|
||||||
val step = vm.state.value.step as AddAccountStep.EnterServer
|
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.
|
// The input survives, so the user can correct it rather than retype it.
|
||||||
assertThat(step.input).isEqualTo("https://example.com/")
|
assertThat(step.input).isEqualTo("https://example.com/")
|
||||||
}
|
}
|
||||||
@@ -333,7 +338,10 @@ class AddAccountViewModelTest {
|
|||||||
): CalDavDiscovery.Outcome {
|
): CalDavDiscovery.Outcome {
|
||||||
discoveries += Call(target, credentials)
|
discoveries += Call(target, credentials)
|
||||||
return discoveryOutcomes.removeFirstOrNull()
|
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
|
override suspend fun startLoginFlow(server: HttpUrl) = loginFlow
|
||||||
|
|||||||
@@ -3,10 +3,12 @@ package de.jeanlucmakiola.caldav
|
|||||||
import at.bitfire.dav4jvm.DavResource
|
import at.bitfire.dav4jvm.DavResource
|
||||||
import at.bitfire.dav4jvm.Response
|
import at.bitfire.dav4jvm.Response
|
||||||
import at.bitfire.dav4jvm.property.CalendarHomeSet
|
import at.bitfire.dav4jvm.property.CalendarHomeSet
|
||||||
|
import at.bitfire.dav4jvm.exception.HttpException
|
||||||
import at.bitfire.dav4jvm.property.CurrentUserPrincipal
|
import at.bitfire.dav4jvm.property.CurrentUserPrincipal
|
||||||
import okhttp3.HttpUrl
|
import okhttp3.HttpUrl
|
||||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
|
import java.io.IOException
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* RFC 6764 discovery: from what the user typed to the list of task collections.
|
* 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). */
|
/** A 200 whose body says the credentials were not accepted (RFC 5397 §3). */
|
||||||
data object Unauthenticated : Outcome
|
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 ->
|
ServiceDiscovery.asBaseUrl(input)?.let { typed ->
|
||||||
if (!typed.isHttps && !allowCleartext) {
|
if (!typed.isHttps && !allowCleartext) {
|
||||||
return Outcome.Failed(
|
return Outcome.Failed(
|
||||||
"\"$input\" is an unencrypted http:// address. Credentials are never sent " +
|
Outcome.Cause.INSECURE,
|
||||||
"over cleartext, so this can only ever answer \"not authorised\".",
|
"\"$input\" is an unencrypted http:// address",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val candidates = ServiceDiscovery.candidatesFor(input, dns)
|
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) {
|
for (candidate in candidates) {
|
||||||
when (val outcome = probe(candidate.url)) {
|
when (val outcome = probe(candidate.url)) {
|
||||||
is Outcome.Found, is Outcome.NeedsAuthentication, Outcome.Unauthenticated -> return outcome
|
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
|
// 401 is not a failure — iCloud and Zoho answer it from
|
||||||
// /.well-known/caldav, which *is* the DAV root, and it is RFC-legal.
|
// /.well-known/caldav, which *is* the DAV root, and it is RFC-legal.
|
||||||
if (isUnauthorized(error)) return Outcome.NeedsAuthentication(listOf(base.host))
|
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
|
// ⚠️ RFC 5397 §3: a 200 carrying <D:unauthenticated/> means the
|
||||||
@@ -148,17 +204,29 @@ class CalDavDiscovery(
|
|||||||
|
|
||||||
val href = principalHref
|
val href = principalHref
|
||||||
?: return if (davCapabilities.contains("calendar-access")) {
|
?: 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 {
|
} 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")) {
|
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)
|
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)
|
return fromPrincipal(principal, movedTo = resource.permanentLocation)
|
||||||
}
|
}
|
||||||
@@ -178,10 +246,12 @@ class CalDavDiscovery(
|
|||||||
}
|
}
|
||||||
homeSetResult.exceptionOrNull()?.let { error ->
|
homeSetResult.exceptionOrNull()?.let { error ->
|
||||||
if (isUnauthorized(error)) return Outcome.NeedsAuthentication(listOf(principal.host))
|
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
|
// 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
|
// 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 crossHost = homeSets.filter { it.host != principal.host }
|
||||||
val insecure = homeSets.filter { principal.isHttps && !it.isHttps }
|
val insecure = homeSets.filter { principal.isHttps && !it.isHttps }
|
||||||
if (insecure.isNotEmpty()) {
|
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>()
|
val collections = linkedMapOf<HttpUrl, TaskCollection>()
|
||||||
@@ -228,6 +301,7 @@ class CalDavDiscovery(
|
|||||||
Outcome.NeedsAuthentication(needAuth.map { it.url.host }.distinct())
|
Outcome.NeedsAuthentication(needAuth.map { it.url.host }.distinct())
|
||||||
} else {
|
} else {
|
||||||
Outcome.Failed(
|
Outcome.Failed(
|
||||||
|
Outcome.Cause.NO_CALENDARS,
|
||||||
failures.firstOrNull()?.reason ?: "no calendar-home-set could be listed",
|
failures.firstOrNull()?.reason ?: "no calendar-home-set could be listed",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -249,5 +323,9 @@ class CalDavDiscovery(
|
|||||||
companion object {
|
companion object {
|
||||||
/** `https://host/path` → the URL, or null. Convenience for callers. */
|
/** `https://host/path` → the URL, or null. Convenience for callers. */
|
||||||
fun url(value: String): HttpUrl? = value.toHttpUrlOrNull()
|
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 root = json.parseToJsonElement(body).jsonObject
|
||||||
val server = root["server"]?.jsonPrimitive?.content?.toHttpUrlOrNull()
|
val server = root["server"]?.jsonPrimitive?.content?.toHttpUrlOrNull()
|
||||||
?: error("no server URL in poll response")
|
?: error("no server URL in poll response")
|
||||||
// Scheme only. A host mismatch here must never discard the credentials:
|
// ⚠️ Coerced, never refused — neither the host nor the scheme may discard
|
||||||
// the 200 is returned exactly once — the server deletes the row inside
|
// the credentials. The 200 is returned exactly once (the server deletes
|
||||||
// poll() before returning — so throwing would burn the app password and
|
// the row inside poll() before answering), so a throw here burns a live
|
||||||
// force the user through the whole flow again.
|
// app password and leaves it dangling in the user's device list.
|
||||||
requireSecureOrigin(flow.pollEndpoint, server)
|
val secureServer = secureOrigin(flow.pollEndpoint, server)
|
||||||
PollResult.Approved(
|
PollResult.Approved(
|
||||||
Credentials(
|
Credentials(
|
||||||
server = server,
|
server = secureServer,
|
||||||
// ⚠️ loginName is what the user typed — possibly an email, an
|
// ⚠️ loginName is what the user typed — possibly an email, an
|
||||||
// LDAP-derived value, or the right name in the wrong case. It is
|
// LDAP-derived value, or the right name in the wrong case. It is
|
||||||
// the Basic auth username and nothing else. Interpolating it into
|
// the Basic auth username and nothing else. Interpolating it into
|
||||||
@@ -182,17 +182,14 @@ class NextcloudLoginFlow(
|
|||||||
}.getOrElse { PollResult.Failed(it.message ?: it.toString()) }
|
}.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
|
* `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
|
* A downgrade to `http` is **fatal here and only here**: this runs *before*
|
||||||
* long-lived app password, which makes it a credential-grade secret.
|
* 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.
|
||||||
* 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.
|
|
||||||
*/
|
*/
|
||||||
internal fun requireSecureOrigin(expected: HttpUrl, actual: HttpUrl) {
|
internal fun requireSecureOrigin(expected: HttpUrl, actual: HttpUrl) {
|
||||||
if (expected.isHttps && !actual.isHttps) {
|
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. */
|
/** A different host than the user typed — reported, not refused. */
|
||||||
internal fun hostMismatchOf(expected: HttpUrl, actual: HttpUrl): HostMismatch? =
|
internal fun hostMismatchOf(expected: HttpUrl, actual: HttpUrl): HostMismatch? =
|
||||||
if (expected.host != actual.host) HostMismatch(expected.host, actual.host) else null
|
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
|
* 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
|
* inherits. Detecting it at account-add time by domain turns a dead end into one
|
||||||
* sentence of instruction.
|
* 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: 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, 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
|
* 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
|
* drops it as a target, so this is a refusal with an explanation rather than
|
||||||
* a 401 the user cannot act on.
|
* 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
|
* 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
|
* clients on that network. Not domain-detectable; set when a server identifies
|
||||||
* itself. Kept here so the engine has one place to ask.
|
* itself. Kept here so the engine has one place to ask.
|
||||||
*/
|
*/
|
||||||
NEXTCLOUD_BRUTE_FORCE_PROTECTED(emptySet()),
|
NEXTCLOUD_BRUTE_FORCE_PROTECTED,
|
||||||
;
|
;
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|
||||||
/** The quirk implied by an email address or a URL host, if any. */
|
/** The quirk implied by an email address or a URL host, if any. */
|
||||||
fun forInput(input: String): ServerQuirk? {
|
fun forInput(input: String): ServerQuirk? = CalDavProvider.forInput(input)?.quirk
|
||||||
val host = ServiceDiscovery.asBaseUrl(input)?.host
|
|
||||||
?: ServiceDiscovery.domainOf(input)
|
|
||||||
?: return null
|
|
||||||
return forHost(host)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun forHost(host: String): ServerQuirk? {
|
fun forHost(host: String): ServerQuirk? = CalDavProvider.forHost(host)?.quirk
|
||||||
val lower = host.lowercase().trimEnd('.')
|
|
||||||
return entries.firstOrNull { quirk ->
|
|
||||||
quirk.domains.any { lower == it || lower.endsWith(".$it") }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun forUrl(url: HttpUrl): ServerQuirk? = forHost(url.host)
|
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. */
|
/** True when discovery should not even be attempted. */
|
||||||
|
|||||||
@@ -71,9 +71,43 @@ object ServiceDiscovery {
|
|||||||
val trimmed = input.trim()
|
val trimmed = input.trim()
|
||||||
if (trimmed.isEmpty()) return emptyList()
|
if (trimmed.isEmpty()) return emptyList()
|
||||||
|
|
||||||
// A typed base URL is used as typed. PROPFIND on it can return principal,
|
// A typed URL is taken as typed and DNS is never consulted — a user who
|
||||||
// home-set and collection in one response, so DNS is never consulted.
|
// gave us an address meant it.
|
||||||
asBaseUrl(trimmed)?.let { return listOf(Candidate(it, "base URL as typed")) }
|
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 domain = domainOf(trimmed) ?: return emptyList()
|
||||||
val candidates = mutableListOf<Candidate>()
|
val candidates = mutableListOf<Candidate>()
|
||||||
|
|||||||
@@ -191,7 +191,10 @@ class CalDavDiscoveryTest {
|
|||||||
// answer "not authorised" — which the user reads as a wrong password.
|
// answer "not authorised" — which the user reads as a wrong password.
|
||||||
val outcome = discovery.discover("http://cloud.example.com/dav/")
|
val outcome = discovery.discover("http://cloud.example.com/dav/")
|
||||||
assertThat(outcome).isInstanceOf(CalDavDiscovery.Outcome.Failed::class.java)
|
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
|
@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!!.actual).isEqualTo("cloud.example.com")
|
||||||
assertThat(mismatch.message).contains("overwrite.cli.url")
|
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
|
@Test
|
||||||
fun `a typed base URL is used as typed and never triggers DNS`() {
|
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"))))
|
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))
|
val candidates = urls("https://cloud.example.com/remote.php/dav/", dns)
|
||||||
.containsExactly("https://cloud.example.com/remote.php/dav/")
|
|
||||||
|
// 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
|
@Test
|
||||||
@@ -125,4 +136,59 @@ class ServiceDiscoveryTest {
|
|||||||
assertThat(ServiceDiscovery.candidatesFor("me@gmx.net", dns).map { it.origin })
|
assertThat(ServiceDiscovery.candidatesFor("me@gmx.net", dns).map { it.origin })
|
||||||
.contains("domain as typed + TXT path=/begenda/dav/users/")
|
.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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -155,3 +155,28 @@ exists in this version:
|
|||||||
Fetch the new tag, diff against `f434c9d`, reapply changes 1–4, run
|
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
|
`./gradlew :dav:test`. If upstream's suite fails, the port is wrong — that is the
|
||||||
entire reason it is vendored alongside the code.
|
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/`.
|
||||||
|
|||||||
@@ -33,4 +33,5 @@ dependencies {
|
|||||||
// vendoring safe, which is the same call provider/PROVENANCE.md made.
|
// vendoring safe, which is the same call provider/PROVENANCE.md made.
|
||||||
testImplementation(libs.junit4)
|
testImplementation(libs.junit4)
|
||||||
testImplementation(libs.okhttp.mockwebserver)
|
testImplementation(libs.okhttp.mockwebserver)
|
||||||
|
testImplementation(libs.okhttp.tls)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -635,12 +635,34 @@ open class DavResource @JvmOverloads constructor(
|
|||||||
if (response.isRedirect)
|
if (response.isRedirect)
|
||||||
// handle 3xx Redirection
|
// handle 3xx Redirection
|
||||||
response.use {
|
response.use {
|
||||||
val target = it.header("Location")?.let { location.resolve(it) }
|
var target = it.header("Location")?.let { location.resolve(it) }
|
||||||
if (target != null) {
|
if (target != null) {
|
||||||
log.fine("Redirected, new location = $target")
|
log.fine("Redirected, new location = $target")
|
||||||
|
|
||||||
if (location.isHttps && !target.isHttps)
|
if (location.isHttps && !target.isHttps) {
|
||||||
throw DavException("Received redirect from HTTPS to HTTP")
|
// ⚠️ 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))
|
if (chainStillPermanent && (it.code == HTTP_MOVED_PERM || it.code == HTTP_PERM_REDIRECT))
|
||||||
permanentLocation = target
|
permanentLocation = target
|
||||||
|
|||||||
@@ -10,10 +10,13 @@ import at.bitfire.dav4jvm.property.CurrentUserPrincipal
|
|||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import okhttp3.mockwebserver.MockResponse
|
import okhttp3.mockwebserver.MockResponse
|
||||||
import okhttp3.mockwebserver.MockWebServer
|
import okhttp3.mockwebserver.MockWebServer
|
||||||
|
import okhttp3.tls.HandshakeCertificates
|
||||||
|
import okhttp3.tls.HeldCertificate
|
||||||
import org.junit.After
|
import org.junit.After
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
import org.junit.Assert.assertFalse
|
import org.junit.Assert.assertFalse
|
||||||
import org.junit.Assert.assertNull
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Assert.assertThrows
|
||||||
import org.junit.Assert.assertTrue
|
import org.junit.Assert.assertTrue
|
||||||
import org.junit.Before
|
import org.junit.Before
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
@@ -242,4 +245,73 @@ class LocalChangesTest {
|
|||||||
TimeZone.setDefault(zone)
|
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 { } }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
was re-requested for ever with nothing counting it, and account removal blocked
|
||||||
on a 30-second connect timeout before anything visible happened.
|
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
|
## What moves to floret-kit
|
||||||
|
|
||||||
Recorded here so the follow-up branch is a file move rather than a rediscovery.
|
Recorded here so the follow-up branch is a file move rather than a rediscovery.
|
||||||
|
|||||||
+1
-1
Submodule floret-kit updated: e047a2bd48...3dc3448265
@@ -156,6 +156,8 @@ androidx-test-rules = { group = "androidx.test", name = "rules", version.ref = "
|
|||||||
# :dav — vendored dav4jvm (see dav/PROVENANCE.md)
|
# :dav — vendored dav4jvm (see dav/PROVENANCE.md)
|
||||||
okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }
|
okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }
|
||||||
okhttp-mockwebserver = { group = "com.squareup.okhttp3", name = "mockwebserver", 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" }
|
xpp3 = { group = "org.ogce", name = "xpp3", version.ref = "xpp3" }
|
||||||
|
|
||||||
# :caldav — RFC 6764 service discovery
|
# :caldav — RFC 6764 service discovery
|
||||||
|
|||||||
Reference in New Issue
Block a user