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