Release 1.1.0 (#51)
Renovate / renovate (push) Successful in 54s
Release — F-Droid repo + Gitea/Codeberg release + Play / detect (push) Successful in 6s
Release — F-Droid repo + Gitea/Codeberg release + Play / release (push) Successful in 21m44s
Release — F-Droid repo + Gitea/Codeberg release + Play / play (push) Successful in 1m32s
Renovate / renovate (push) Successful in 54s
Release — F-Droid repo + Gitea/Codeberg release + Play / detect (push) Successful in 6s
Release — F-Droid repo + Gitea/Codeberg release + Play / release (push) Successful in 21m44s
Release — F-Droid repo + Gitea/Codeberg release + Play / play (push) Successful in 1m32s
Agendula 1.1.0. Merging this triggers the release pipeline (F-Droid, Codeberg, Play) and mints the v1.1.0 tag. - Tags, synced as CATEGORIES (#35) - Saved filters, also usable in the Tasks widget (#47) - Offline flavor without sync or network, published to Codeberg only (#39) - New Today, Up next, Lists and Week widgets; widgets can show several lists - Synced lists can be renamed and deleted - All-day tasks no longer land a day off west of UTC in smart lists and filters versionName 1.1.0 / versionCode 1010099. CHANGELOG has the 1.1.0 section, and What's New is written for en-US, en-GB, de-DE, pt-BR, plus new fr-FR and ar. After the tag lands, fdroiddata!49998 needs a 1.1.0 build entry with gradle: full. Closes #35, closes #39, closes #47 Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de> Reviewed-on: https://codeberg.org/jlmakiola/agendula/pulls/51
This commit is contained in:
1 parent
b6f0391cb3
commit
dd08c035df
219 files changed
+7010
-1227
No files matched your search
@@ -10,10 +10,10 @@ import dagger.hilt.android.EntryPointAccessors
|
||||
import dagger.hilt.android.HiltAndroidApp
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import de.jeanlucmakiola.agendula.data.di.ApplicationScope
|
||||
import de.jeanlucmakiola.agendula.data.di.ChannelRefresher
|
||||
import de.jeanlucmakiola.agendula.data.reminders.ReminderMaintenanceWorker
|
||||
import de.jeanlucmakiola.agendula.data.reminders.ReminderScheduler
|
||||
import de.jeanlucmakiola.agendula.data.reminders.TaskNotifier
|
||||
import de.jeanlucmakiola.agendula.data.sync.SyncNoticeNotifier
|
||||
import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver
|
||||
import de.jeanlucmakiola.agendula.data.tasks.StartupGate
|
||||
import de.jeanlucmakiola.agendula.data.tasks.room.DatabaseCheckpoint
|
||||
@@ -105,7 +105,7 @@ class AgendulaApp : Application(), Configuration.Provider {
|
||||
val entryPoint = EntryPointAccessors.fromApplication(this, AppEntryPoint::class.java)
|
||||
runCatching {
|
||||
entryPoint.taskNotifier().refreshChannel()
|
||||
entryPoint.syncNoticeNotifier().refreshChannel()
|
||||
entryPoint.channelRefreshers().forEach { it.refreshChannel() }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,7 +120,7 @@ class AgendulaApp : Application(), Configuration.Provider {
|
||||
fun applicationScope(): CoroutineScope
|
||||
fun databaseCheckpoint(): DatabaseCheckpoint
|
||||
fun taskNotifier(): TaskNotifier
|
||||
fun syncNoticeNotifier(): SyncNoticeNotifier
|
||||
fun channelRefreshers(): Set<@JvmSuppressWildcards ChannelRefresher>
|
||||
fun taskWidgetUpdater(): TaskWidgetUpdater
|
||||
}
|
||||
}
|
||||
@@ -25,7 +25,6 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import de.jeanlucmakiola.agendula.data.di.LaunchHook
|
||||
import de.jeanlucmakiola.agendula.data.sync.SyncNoticeNotifier
|
||||
import de.jeanlucmakiola.agendula.domain.SmartList
|
||||
import de.jeanlucmakiola.agendula.ui.imports.importIntentUri
|
||||
import de.jeanlucmakiola.agendula.ui.navigation.AppShortcuts
|
||||
@@ -33,10 +32,6 @@ import de.jeanlucmakiola.agendula.data.prefs.ThemeMode
|
||||
import de.jeanlucmakiola.agendula.ui.RootScreen
|
||||
import de.jeanlucmakiola.agendula.ui.crash.CrashReportActivity
|
||||
import de.jeanlucmakiola.agendula.ui.navigation.NavRequest
|
||||
import de.jeanlucmakiola.agendula.data.sync.AccountRepository
|
||||
import de.jeanlucmakiola.agendula.data.sync.PendingLoginFlowStore
|
||||
import de.jeanlucmakiola.agendula.data.sync.SyncTrigger
|
||||
import de.jeanlucmakiola.agendula.data.sync.push.PushRegistrar
|
||||
import de.jeanlucmakiola.agendula.ui.settings.SettingsViewModel
|
||||
import de.jeanlucmakiola.agendula.ui.theme.AgendulaTheme
|
||||
import de.jeanlucmakiola.floret.crash.CrashReportDialog
|
||||
@@ -55,14 +50,6 @@ class MainActivity : ComponentActivity() {
|
||||
|
||||
@Inject lateinit var launchHooks: Set<@JvmSuppressWildcards LaunchHook>
|
||||
|
||||
@Inject lateinit var accounts: AccountRepository
|
||||
|
||||
@Inject lateinit var syncTrigger: SyncTrigger
|
||||
|
||||
@Inject lateinit var pendingLoginFlows: PendingLoginFlowStore
|
||||
|
||||
@Inject lateinit var push: PushRegistrar
|
||||
|
||||
// A captured crash report awaiting the user's decision, surfaced as a dialog
|
||||
// over the app on the next launch (the single-crash path). A startup
|
||||
// crash-loop is handled out of band, before setContent — see below.
|
||||
@@ -94,34 +81,9 @@ class MainActivity : ComponentActivity() {
|
||||
// Surface a single captured crash as a dialog on the next launch.
|
||||
if (CrashReporter.shouldPrompt(this)) pendingCrashReport = CrashReporter.pendingReport(this)
|
||||
|
||||
// Sync hard on app open: the periodic worker's interval is a floor, and
|
||||
// in the `rare` and `restricted` App Standby buckets it may not have run
|
||||
// at all. `KEEP` makes rescheduling idempotent, so this also repairs a
|
||||
// schedule lost to "clear app data" or to a restore.
|
||||
//
|
||||
// ⚠️ Only on a genuine open. This activity declares no `configChanges`,
|
||||
// so onCreate runs again on every rotation, theme switch, locale change
|
||||
// and font-scale change — each of which would otherwise start a fresh
|
||||
// network sync the moment the previous one finished.
|
||||
// Each hook on its own, so one waiting on the network holds up no other.
|
||||
if (savedInstanceState == null) {
|
||||
lifecycleScope.launch {
|
||||
runCatching {
|
||||
accounts.rescheduleAll()
|
||||
accounts.syncable().forEach { syncTrigger.enqueue(it.displayName) }
|
||||
// A login flow the previous process died in the middle of.
|
||||
// Its password, if the user approved, exists nowhere else.
|
||||
pendingLoginFlows.reclaim()
|
||||
}
|
||||
// Last and on its own: it waits on the network, and must not
|
||||
// hold up the reclaim above. Re-registering on open is what
|
||||
// the connector recommends.
|
||||
runCatching { push.updateAll() }
|
||||
}
|
||||
}
|
||||
|
||||
// Variant hooks: the demo seeder in debug builds, nothing in release.
|
||||
if (savedInstanceState == null) {
|
||||
lifecycleScope.launch { launchHooks.forEach { runCatching { it.onLaunch(intent) } } }
|
||||
launchHooks.forEach { hook -> lifecycleScope.launch { runCatching { hook.onLaunch(intent) } } }
|
||||
}
|
||||
setContent {
|
||||
val settingsViewModel: SettingsViewModel = hiltViewModel()
|
||||
@@ -180,12 +142,18 @@ class MainActivity : ComponentActivity() {
|
||||
const val EXTRA_TASK_ID = "de.jeanlucmakiola.agendula.extra.TASK_ID"
|
||||
const val EXTRA_OCCURRENCE_START = "de.jeanlucmakiola.agendula.extra.OCCURRENCE_START"
|
||||
private const val EXTRA_OPEN_ACCOUNTS = "de.jeanlucmakiola.agendula.extra.OPEN_ACCOUNTS"
|
||||
|
||||
/** The account a sign-in notification is about, on the intent it opens. */
|
||||
const val EXTRA_SIGN_IN_ACCOUNT_ID = "de.jeanlucmakiola.agendula.extra.SIGN_IN_ACCOUNT_ID"
|
||||
const val ACTION_NEW_TASK = "de.jeanlucmakiola.agendula.action.NEW_TASK"
|
||||
const val ACTION_TODAY = "de.jeanlucmakiola.agendula.action.TODAY"
|
||||
private const val ACTION_OPEN_SMART = "de.jeanlucmakiola.agendula.action.OPEN_SMART"
|
||||
private const val ACTION_OPEN_LIST = "de.jeanlucmakiola.agendula.action.OPEN_LIST"
|
||||
private const val ACTION_OPEN_FILTER = "de.jeanlucmakiola.agendula.action.OPEN_FILTER"
|
||||
private const val EXTRA_FILTER_ID = "de.jeanlucmakiola.agendula.extra.FILTER_ID"
|
||||
private const val EXTRA_SMART_LIST = "de.jeanlucmakiola.agendula.extra.SMART_LIST"
|
||||
private const val EXTRA_LIST_ID = "de.jeanlucmakiola.agendula.extra.LIST_ID"
|
||||
private const val EXTRA_PRESET_LIST_ID = "de.jeanlucmakiola.agendula.extra.PRESET_LIST_ID"
|
||||
private const val SHARED_TITLE_LIMIT = 500
|
||||
private const val NO_OCCURRENCE = -1L
|
||||
|
||||
@@ -206,9 +174,14 @@ class MainActivity : ComponentActivity() {
|
||||
.putExtra(EXTRA_OPEN_ACCOUNTS, true)
|
||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
|
||||
/** The launcher shortcut's and Quick Settings tile's "New task". */
|
||||
fun newTaskIntent(context: Context): Intent =
|
||||
Intent(ACTION_NEW_TASK, null, context, MainActivity::class.java)
|
||||
/**
|
||||
* The launcher shortcut's, Quick Settings tile's and a widget's "New
|
||||
* task". [listId] preset the list when it names exactly one; the data
|
||||
* URI keeps each widget's PendingIntent apart from the others.
|
||||
*/
|
||||
fun newTaskIntent(context: Context, listId: Long? = null): Intent =
|
||||
Intent(ACTION_NEW_TASK, listId?.let { "agendula://newtask/$it".toUri() }, context, MainActivity::class.java)
|
||||
.putExtra(EXTRA_PRESET_LIST_ID, listId ?: -1L)
|
||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
|
||||
fun todayIntent(context: Context): Intent =
|
||||
@@ -227,19 +200,31 @@ class MainActivity : ComponentActivity() {
|
||||
.putExtra(EXTRA_LIST_ID, listId)
|
||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
|
||||
/** Opens one saved filter. */
|
||||
fun filterIntent(context: Context, filterId: Long): Intent =
|
||||
Intent(ACTION_OPEN_FILTER, "agendula://filter/$filterId".toUri(), context, MainActivity::class.java)
|
||||
.putExtra(EXTRA_FILTER_ID, filterId)
|
||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
|
||||
internal fun navRequestOf(intent: Intent?): NavRequest? {
|
||||
if (intent == null) return null
|
||||
intent.getLongExtra(SyncNoticeNotifier.EXTRA_SIGN_IN_ACCOUNT_ID, -1L).takeIf { it > 0L }
|
||||
?.let { return NavRequest.OpenAccount(it) }
|
||||
if (intent.getBooleanExtra(EXTRA_OPEN_ACCOUNTS, false)) return NavRequest.OpenAccounts
|
||||
if (BuildConfig.SYNC_ENABLED) {
|
||||
intent.getLongExtra(EXTRA_SIGN_IN_ACCOUNT_ID, -1L).takeIf { it > 0L }
|
||||
?.let { return NavRequest.OpenAccount(it) }
|
||||
if (intent.getBooleanExtra(EXTRA_OPEN_ACCOUNTS, false)) return NavRequest.OpenAccounts
|
||||
}
|
||||
when (intent.action) {
|
||||
ACTION_NEW_TASK -> return NavRequest.NewTask()
|
||||
ACTION_NEW_TASK -> return NavRequest.NewTask(
|
||||
listId = intent.getLongExtra(EXTRA_PRESET_LIST_ID, -1L).takeIf { it > 0L },
|
||||
)
|
||||
ACTION_TODAY -> return NavRequest.OpenSmart(SmartList.TODAY)
|
||||
ACTION_OPEN_SMART -> intent.getStringExtra(EXTRA_SMART_LIST)
|
||||
?.let { name -> SmartList.entries.firstOrNull { it.name == name } }
|
||||
?.let { return NavRequest.OpenSmart(it) }
|
||||
ACTION_OPEN_LIST -> intent.getLongExtra(EXTRA_LIST_ID, -1L).takeIf { it > 0L }
|
||||
?.let { return NavRequest.OpenList(it) }
|
||||
ACTION_OPEN_FILTER -> intent.getLongExtra(EXTRA_FILTER_ID, -1L).takeIf { it > 0L }
|
||||
?.let { return NavRequest.OpenFilter(it) }
|
||||
}
|
||||
importIntentUri(intent)?.let { return NavRequest.Import(it) }
|
||||
if (intent.action == Intent.ACTION_SEND && intent.type?.startsWith("text/plain") == true) {
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
package de.jeanlucmakiola.agendula.data.di
|
||||
|
||||
import dagger.Module
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import dagger.multibindings.Multibinds
|
||||
|
||||
/**
|
||||
* A notification channel a build variant adds, re-created in the current
|
||||
* language on start and on a configuration change. The `full` flavor
|
||||
* contributes the sync notices; `offline` has none.
|
||||
*/
|
||||
fun interface ChannelRefresher {
|
||||
fun refreshChannel()
|
||||
}
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
abstract class ChannelRefresherModule {
|
||||
@Multibinds
|
||||
abstract fun channelRefreshers(): Set<ChannelRefresher>
|
||||
}
|
||||
@@ -12,13 +12,6 @@ import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
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.LoginFlowRecord
|
||||
import de.jeanlucmakiola.agendula.data.sync.PendingLoginFlowStore
|
||||
import de.jeanlucmakiola.agendula.data.sync.SyncOnEdit
|
||||
import de.jeanlucmakiola.agendula.data.sync.OkHttpCalDavGateway
|
||||
import de.jeanlucmakiola.agendula.data.tasks.AndroidProviderEnvironment
|
||||
import de.jeanlucmakiola.agendula.data.tasks.AndroidTasksDataSource
|
||||
import de.jeanlucmakiola.agendula.data.tasks.ModeRoutingTasksDataSource
|
||||
@@ -27,7 +20,6 @@ import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver
|
||||
import de.jeanlucmakiola.agendula.data.tasks.TasksDataSource
|
||||
import de.jeanlucmakiola.agendula.data.tasks.TasksRepository
|
||||
import de.jeanlucmakiola.agendula.data.tasks.TasksRepositoryImpl
|
||||
import de.jeanlucmakiola.agendula.data.tasks.room.LocalWriteListener
|
||||
import de.jeanlucmakiola.agendula.data.tasks.room.RoomTasksDataSource
|
||||
import de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase
|
||||
import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler
|
||||
@@ -51,37 +43,13 @@ import javax.inject.Singleton
|
||||
* reconciled", and the credential store to accounts that ask to be signed in
|
||||
* again — all states the app already knows how to be in, unlike a launch loop.
|
||||
*/
|
||||
private fun replaceCorrupted() = ReplaceFileCorruptionHandler { emptyPreferences() }
|
||||
internal fun replaceCorrupted() = ReplaceFileCorruptionHandler { emptyPreferences() }
|
||||
|
||||
private val Context.agendulaDataStore: DataStore<Preferences> by preferencesDataStore(
|
||||
name = "agendula_prefs",
|
||||
corruptionHandler = replaceCorrupted(),
|
||||
)
|
||||
|
||||
/** See [CredentialsDataStore] for why this is a separate file. */
|
||||
private val Context.credentialsDataStore: DataStore<Preferences> by preferencesDataStore(
|
||||
name = CREDENTIALS_DATASTORE,
|
||||
corruptionHandler = replaceCorrupted(),
|
||||
)
|
||||
|
||||
/** See [SyncStateDataStore] for why this is a separate file. */
|
||||
private val Context.syncStateDataStore: DataStore<Preferences> by preferencesDataStore(
|
||||
name = SYNC_STATE_DATASTORE,
|
||||
corruptionHandler = replaceCorrupted(),
|
||||
)
|
||||
|
||||
/**
|
||||
* Named here and in `backup_rules.xml` / `data_extraction_rules.xml`, which
|
||||
* exclude `datastore/$CREDENTIALS_DATASTORE.preferences_pb` by this name.
|
||||
*/
|
||||
const val CREDENTIALS_DATASTORE = "agendula_credentials"
|
||||
|
||||
/**
|
||||
* Named here and in `backup_rules.xml` / `data_extraction_rules.xml`, which
|
||||
* exclude `datastore/$SYNC_STATE_DATASTORE.preferences_pb` by this name.
|
||||
*/
|
||||
const val SYNC_STATE_DATASTORE = "agendula_sync_state"
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
abstract class DataBindModule {
|
||||
@@ -94,22 +62,6 @@ abstract class DataBindModule {
|
||||
@Singleton
|
||||
abstract fun bindProviderEnvironment(impl: AndroidProviderEnvironment): ProviderEnvironment
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
abstract fun bindCalDavGateway(impl: OkHttpCalDavGateway): CalDavGateway
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
abstract fun bindAccountCreator(impl: AccountRepository): AccountCreator
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
abstract fun bindLoginFlowRecord(impl: PendingLoginFlowStore): LoginFlowRecord
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
abstract fun bindLocalWriteListener(impl: SyncOnEdit): LocalWriteListener
|
||||
|
||||
// Deliberately unqualified-free of the routing above: this is the external
|
||||
// store itself, for the one caller that has to read it while another store is
|
||||
// the active one.
|
||||
@@ -128,18 +80,6 @@ object DataProvideModule {
|
||||
fun provideDataStore(@ApplicationContext context: Context): DataStore<Preferences> =
|
||||
context.agendulaDataStore
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
@CredentialsDataStore
|
||||
fun provideCredentialsDataStore(@ApplicationContext context: Context): DataStore<Preferences> =
|
||||
context.credentialsDataStore
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
@SyncStateDataStore
|
||||
fun provideSyncStateDataStore(@ApplicationContext context: Context): DataStore<Preferences> =
|
||||
context.syncStateDataStore
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideTasksDatabase(@ApplicationContext context: Context): TasksDatabase =
|
||||
@@ -149,6 +89,7 @@ object DataProvideModule {
|
||||
// backed-up `.db` does not. The backup rules carry all three files and
|
||||
// the app checkpoints on ON_STOP.
|
||||
.setJournalMode(RoomDatabase.JournalMode.WRITE_AHEAD_LOGGING)
|
||||
.addMigrations(TasksDatabase.MIGRATION_1_2)
|
||||
.build()
|
||||
|
||||
/**
|
||||
|
||||
@@ -7,9 +7,8 @@ import dagger.hilt.components.SingletonComponent
|
||||
import dagger.multibindings.Multibinds
|
||||
|
||||
/**
|
||||
* Something a build variant wants to run when the app is launched with [Intent].
|
||||
* Release builds contribute none; the debug source set adds the demo seeder, so
|
||||
* that class never ships.
|
||||
* Something a build variant runs on a genuine app open (not on a configuration
|
||||
* change): sync in the `full` flavor, the demo seeder in debug builds.
|
||||
*/
|
||||
fun interface LaunchHook {
|
||||
suspend fun onLaunch(intent: Intent)
|
||||
|
||||
@@ -17,37 +17,6 @@ annotation class IoDispatcher
|
||||
@Retention(AnnotationRetention.BINARY)
|
||||
annotation class ApplicationScope
|
||||
|
||||
/**
|
||||
* Marks the DataStore holding **only** the Keystore-encrypted app passwords.
|
||||
*
|
||||
* A separate file from `agendula_prefs` on purpose: Auto Backup includes
|
||||
* `datastore/`, and a restored ciphertext is permanently undecryptable because
|
||||
* Keystore keys are non-exportable. Its own file is what lets the backup rules
|
||||
* exclude the credentials and nothing else — excluding the whole database or
|
||||
* all of DataStore would trade a latent bug for a live one.
|
||||
*/
|
||||
@Qualifier
|
||||
@Retention(AnnotationRetention.BINARY)
|
||||
annotation class CredentialsDataStore
|
||||
|
||||
/**
|
||||
* Marks the DataStore holding per-device **sync bookkeeping** — the quarantine
|
||||
* counters and the full-reconciliation clock.
|
||||
*
|
||||
* Its own file for the same reason the credentials have one: Auto Backup
|
||||
* includes `datastore/`, and every value in here is a statement about *this*
|
||||
* device's conversation with a server. Restored onto a new install they are all
|
||||
* lies, and two of them are dangerous — a restored "reconciled recently" makes
|
||||
* the engine trust a sync token for another day, which is precisely the silently
|
||||
* pruned change log the full path exists to catch, and a restored quarantine
|
||||
* count silently skips resources that were never tried here.
|
||||
*
|
||||
* Not user data, so nothing is lost by excluding it.
|
||||
*/
|
||||
@Qualifier
|
||||
@Retention(AnnotationRetention.BINARY)
|
||||
annotation class SyncStateDataStore
|
||||
|
||||
/**
|
||||
* Marks the **external** provider's [de.jeanlucmakiola.agendula.data.tasks
|
||||
* .TasksDataSource] — the OpenTasks/tasks.org path specifically, rather than
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
package de.jeanlucmakiola.agendula.data.prefs
|
||||
|
||||
import de.jeanlucmakiola.agendula.domain.DueWindow
|
||||
import de.jeanlucmakiola.agendula.domain.Priority
|
||||
import de.jeanlucmakiola.agendula.domain.SavedFilter
|
||||
import de.jeanlucmakiola.agendula.domain.TagMatch
|
||||
import java.net.URLDecoder
|
||||
import java.net.URLEncoder
|
||||
|
||||
/**
|
||||
* Stored form of the saved filters: one line per filter,
|
||||
* `id|name|due|lists|tags|match|priority`, with name and tags URL-encoded and
|
||||
* list ids qualified by the store they belong to (`OWN:3`), since each store
|
||||
* numbers its lists on its own. Fixed once released — don't change without a
|
||||
* data migration.
|
||||
*/
|
||||
internal object SavedFilterCodec {
|
||||
|
||||
/** The filters in [raw], with only the list ids of [mode]'s store. */
|
||||
fun decode(raw: String?, mode: String): List<SavedFilter> =
|
||||
lines(raw).mapNotNull { runCatching { decodeLine(it, mode) }.getOrNull() }
|
||||
|
||||
/** [raw] with [filter] replacing the line of the same id, or appended. */
|
||||
fun upsert(raw: String?, filter: SavedFilter, mode: String): String {
|
||||
val existing = lines(raw)
|
||||
val index = existing.indexOfFirst { idOf(it) == filter.id }
|
||||
// Keep the lists another store contributed, so switching stores and back
|
||||
// doesn't widen a filter that was edited in between, unless "any list"
|
||||
// was chosen here, which drops them on purpose.
|
||||
val foreign = if (filter.anyList) {
|
||||
emptyList()
|
||||
} else {
|
||||
existing.getOrNull(index)?.let(::fields)?.getOrNull(3)
|
||||
?.let(::splitList)?.filterNot { it.startsWith("$mode:") }.orEmpty()
|
||||
}
|
||||
val line = encodeLine(filter, mode, foreign)
|
||||
val updated = if (index >= 0) existing.toMutableList().also { it[index] = line } else existing + line
|
||||
return updated.joinToString("\n")
|
||||
}
|
||||
|
||||
fun remove(raw: String?, id: Long): String = lines(raw).filterNot { idOf(it) == id }.joinToString("\n")
|
||||
|
||||
private fun lines(raw: String?): List<String> = raw.orEmpty().split('\n').filter { it.isNotBlank() }
|
||||
|
||||
private fun fields(line: String): List<String> = line.split('|')
|
||||
|
||||
private fun idOf(line: String): Long? = fields(line).firstOrNull()?.toLongOrNull()
|
||||
|
||||
private fun splitList(value: String): List<String> = value.split(',').filter { it.isNotEmpty() }
|
||||
|
||||
private fun encodeLine(filter: SavedFilter, mode: String, foreignLists: List<String>): String =
|
||||
listOf(
|
||||
filter.id.toString(),
|
||||
encode(filter.name),
|
||||
filter.due.sorted().joinToString(",") { it.name },
|
||||
(filter.listIds.sorted().map { "$mode:$it" } + foreignLists).joinToString(","),
|
||||
filter.tags.sorted().joinToString(",") { encode(it) },
|
||||
filter.tagMatch.name,
|
||||
filter.minPriority.name,
|
||||
).joinToString("|")
|
||||
|
||||
private fun decodeLine(line: String, mode: String): SavedFilter? {
|
||||
val f = fields(line)
|
||||
if (f.size < 7) return null
|
||||
val id = f[0].toLongOrNull() ?: return null
|
||||
val lists = splitList(f[3])
|
||||
val here = lists.filter { it.startsWith("$mode:") }.mapNotNull { it.removePrefix("$mode:").toLongOrNull() }.toSet()
|
||||
return SavedFilter(
|
||||
id = id,
|
||||
name = decode(f[1]),
|
||||
due = splitList(f[2]).mapNotNull { runCatching { DueWindow.valueOf(it) }.getOrNull() }.toSet(),
|
||||
listIds = here,
|
||||
listsElsewhere = lists.any { !it.startsWith("$mode:") },
|
||||
tags = splitList(f[4]).map(::decode).toSet(),
|
||||
tagMatch = runCatching { TagMatch.valueOf(f[5]) }.getOrDefault(TagMatch.ANY),
|
||||
minPriority = runCatching { Priority.valueOf(f[6]) }.getOrDefault(Priority.NONE),
|
||||
)
|
||||
}
|
||||
|
||||
private fun encode(value: String): String = URLEncoder.encode(value, Charsets.UTF_8.name())
|
||||
|
||||
private fun decode(value: String): String = URLDecoder.decode(value, Charsets.UTF_8.name())
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.datastore.preferences.core.stringSetPreferencesKey
|
||||
import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver
|
||||
import de.jeanlucmakiola.agendula.data.tasks.StorageMode
|
||||
import de.jeanlucmakiola.agendula.domain.SavedFilter
|
||||
import de.jeanlucmakiola.agendula.domain.Task
|
||||
import de.jeanlucmakiola.agendula.domain.TaskFilter
|
||||
import de.jeanlucmakiola.agendula.domain.TaskFormField
|
||||
@@ -20,6 +21,7 @@ import de.jeanlucmakiola.floret.reminders.applyReminderOverride
|
||||
import de.jeanlucmakiola.floret.reminders.normalizeReminders
|
||||
import de.jeanlucmakiola.floret.reminders.reminderLeadsFor
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import java.time.DayOfWeek
|
||||
import kotlinx.coroutines.flow.map
|
||||
import javax.inject.Inject
|
||||
@@ -95,6 +97,8 @@ data class Settings(
|
||||
val pushEnabled: Boolean = true,
|
||||
/** Lists of the current store whose tasks the smart lists (and their counts) leave out. */
|
||||
val hiddenFromSmartLists: Set<Long> = emptySet(),
|
||||
/** Pre-fill a new task's start with today's date. */
|
||||
val defaultStartToday: Boolean = false,
|
||||
) {
|
||||
/** [tasks] as [filter] shows them: a smart list drops the lists kept out of it. */
|
||||
fun visibleIn(filter: TaskFilter, tasks: List<Task>): List<Task> =
|
||||
@@ -153,6 +157,7 @@ class SettingsPrefs @Inject constructor(
|
||||
.mapNotNull { entry -> entry.substringAfter("$mode:", "").toLongOrNull() }
|
||||
.toSet()
|
||||
},
|
||||
defaultStartToday = p[DEFAULT_START_TODAY] ?: false,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -166,6 +171,8 @@ class SettingsPrefs @Inject constructor(
|
||||
|
||||
suspend fun setAutofocusTitle(enabled: Boolean) = dataStore.edit { it[AUTOFOCUS_TITLE] = enabled }
|
||||
|
||||
suspend fun setDefaultStartToday(enabled: Boolean) = dataStore.edit { it[DEFAULT_START_TODAY] = enabled }
|
||||
|
||||
suspend fun setTimeFormat(pref: TimeFormatPref) = dataStore.edit { it[TIME_FORMAT] = pref.name }
|
||||
|
||||
suspend fun setWeekStart(day: DayOfWeek?) = dataStore.edit {
|
||||
@@ -254,6 +261,34 @@ class SettingsPrefs @Inject constructor(
|
||||
p[SMART_LIST_HIDDEN] = if (hidden) current + entry else current - entry
|
||||
}
|
||||
|
||||
/** The saved filters, oldest first, with the list ids of the current store. */
|
||||
val savedFilters: Flow<List<SavedFilter>> =
|
||||
dataStore.data.map { p -> p[SAVED_FILTERS] to p[STORAGE_MODE] }
|
||||
.distinctUntilChanged()
|
||||
.map { (raw, mode) -> SavedFilterCodec.decode(raw, (storedMode(mode) ?: resolver.autoMode()).name) }
|
||||
.distinctUntilChanged()
|
||||
|
||||
/** One saved filter; `null` while it doesn't exist. */
|
||||
fun savedFilter(id: Long): Flow<SavedFilter?> =
|
||||
savedFilters.map { all -> all.firstOrNull { it.id == id } }.distinctUntilChanged()
|
||||
|
||||
/** Store [filter]; an id of 0 makes a new one. Returns the filter's id. */
|
||||
suspend fun saveFilter(filter: SavedFilter): Long {
|
||||
var id = filter.id
|
||||
dataStore.edit { p ->
|
||||
if (id <= 0) {
|
||||
id = p[SAVED_FILTER_NEXT_ID] ?: 1L
|
||||
p[SAVED_FILTER_NEXT_ID] = id + 1
|
||||
}
|
||||
p[SAVED_FILTERS] = SavedFilterCodec.upsert(p[SAVED_FILTERS], filter.copy(id = id), modeOf(p).name)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
suspend fun deleteFilter(id: Long) = dataStore.edit { p ->
|
||||
p[SAVED_FILTERS] = SavedFilterCodec.remove(p[SAVED_FILTERS], id)
|
||||
}
|
||||
|
||||
suspend fun setDefaultEditFields(fields: Set<TaskFormField>) = dataStore.edit {
|
||||
it[DEFAULT_EDIT_FIELDS] = fields.mapTo(mutableSetOf()) { field -> field.name }
|
||||
}
|
||||
@@ -282,6 +317,9 @@ class SettingsPrefs @Inject constructor(
|
||||
val SYNC_INTERVAL = intPreferencesKey("sync_interval_minutes")
|
||||
val PUSH_ENABLED = booleanPreferencesKey("push_enabled")
|
||||
val SMART_LIST_HIDDEN = stringSetPreferencesKey("smart_list_hidden")
|
||||
val DEFAULT_START_TODAY = booleanPreferencesKey("default_start_today")
|
||||
val SAVED_FILTERS = stringPreferencesKey("saved_filters")
|
||||
val SAVED_FILTER_NEXT_ID = longPreferencesKey("saved_filter_next_id")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,604 +0,0 @@
|
||||
package de.jeanlucmakiola.agendula.data.sync
|
||||
|
||||
import de.jeanlucmakiola.agendula.data.di.IoDispatcher
|
||||
import de.jeanlucmakiola.agendula.data.sync.push.PushRegistrar
|
||||
import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.agendula.data.tasks.room.AccountEntity
|
||||
import de.jeanlucmakiola.agendula.data.tasks.room.TaskListEntity
|
||||
import de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase
|
||||
import de.jeanlucmakiola.caldav.CalDavDiscovery
|
||||
import de.jeanlucmakiola.caldav.TaskCollection
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.NonCancellable
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* The one thing the sign-in flow needs from [AccountRepository].
|
||||
*
|
||||
* A seam, so the flow's state machine can be tested without a database, a
|
||||
* Keystore or an `AccountManager` — the three things that make the rest of this
|
||||
* class Android-only.
|
||||
*/
|
||||
interface AccountCreator {
|
||||
suspend fun create(
|
||||
displayName: String,
|
||||
username: String,
|
||||
appPassword: String,
|
||||
found: CalDavDiscovery.Outcome.Found,
|
||||
selected: Set<TaskCollection>,
|
||||
/** An existing account to sign in again, instead of creating one. */
|
||||
reauthenticating: Long? = null,
|
||||
): AccountRepository.Outcome
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns a finished sign-in into an account that exists in all three places it
|
||||
* has to: the Room `accounts` row, the encrypted credential, and the
|
||||
* system-visible `AccountManager` entry.
|
||||
*
|
||||
* The order matters. The Room row comes first because its id keys the
|
||||
* credential, and the system account comes last because it is the one thing a
|
||||
* user can see — an entry in Settings for an account whose credential failed to
|
||||
* store would be a sync that silently never works.
|
||||
*/
|
||||
@Singleton
|
||||
class AccountRepository @Inject constructor(
|
||||
private val database: TasksDatabase,
|
||||
private val credentials: CredentialStore,
|
||||
private val accounts: CalDavAccounts,
|
||||
private val syncTrigger: SyncTrigger,
|
||||
private val cadence: SyncCadenceStore,
|
||||
private val accountState: AccountStateStore,
|
||||
private val quarantine: QuarantineStore,
|
||||
private val notices: SyncNoticeStore,
|
||||
private val collectionSupport: CollectionSupportStore,
|
||||
private val gateway: CalDavGateway,
|
||||
private val availability: SyncAvailability,
|
||||
private val settings: SettingsPrefs,
|
||||
private val push: PushRegistrar,
|
||||
@IoDispatcher private val io: CoroutineDispatcher,
|
||||
) : AccountCreator {
|
||||
|
||||
private suspend fun syncInterval(): Int = settings.settings.first().syncIntervalMinutes
|
||||
|
||||
/** What went wrong, in words a user can act on. */
|
||||
sealed interface Outcome {
|
||||
data class Created(val accountId: Long) : Outcome
|
||||
data object AlreadyExists : Outcome
|
||||
|
||||
/** External storage mode is on, and nothing would ever show what this account syncs. */
|
||||
data object ExternalStorage : Outcome
|
||||
data class CredentialFailed(val cause: Cause, val detail: String = "") : Outcome
|
||||
|
||||
/**
|
||||
* Why an account could not be saved, in a form the UI can translate.
|
||||
*
|
||||
* ⚠️ The UI renders *this*, never [CredentialFailed.detail] — which is
|
||||
* a `Throwable.message` and so an untranslated, often unreadable string.
|
||||
*/
|
||||
enum class Cause {
|
||||
/** The Keystore refused to hold the password. */
|
||||
KEYSTORE_REFUSED,
|
||||
|
||||
/** Anything else that stopped the write. */
|
||||
NOT_SAVED,
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun all(): List<AccountEntity> = withContext(io) { database.accounts().all() }
|
||||
|
||||
/** The accounts, observed, so a sync landing updates a screen that is open. */
|
||||
fun observeAll(): Flow<List<AccountEntity>> = database.accounts().observeAll()
|
||||
|
||||
/** Every account's synced lists. */
|
||||
fun observeSyncedLists(): Flow<List<TaskListEntity>> = database.taskLists().observeSynced()
|
||||
|
||||
/**
|
||||
* Creates an account and the task lists the user chose.
|
||||
*
|
||||
* [selected] is a subset of what discovery found; a collection the user did
|
||||
* not tick is simply not created, and can be added later without touching
|
||||
* anything else — `task_lists.account_id` is a nullable FK, so attaching is
|
||||
* an `UPDATE`.
|
||||
*/
|
||||
override suspend fun create(
|
||||
displayName: String,
|
||||
username: String,
|
||||
appPassword: String,
|
||||
found: CalDavDiscovery.Outcome.Found,
|
||||
selected: Set<TaskCollection>,
|
||||
reauthenticating: Long?,
|
||||
): Outcome = withContext(io) {
|
||||
if (!availability.accountsUsable()) return@withContext Outcome.ExternalStorage
|
||||
// "Sign in again" names its account, so a login name the server spells
|
||||
// differently from last time still lands on it rather than beside it.
|
||||
// Only on the same server: anything else is a different account.
|
||||
val target = reauthenticating?.let { database.accounts().account(it) }
|
||||
if (target != null && sameServer(target, found)) {
|
||||
return@withContext reauthenticate(target, target.displayName, username, appPassword, found, selected)
|
||||
}
|
||||
// Both stores, not just one. There is no unique index on
|
||||
// accounts.display_name and nothing prunes Room when the system account
|
||||
// disappears, so "removed from system Settings, re-added here" would
|
||||
// otherwise leave a second Room row and a duplicate of every list.
|
||||
val systemAccount = accounts.find(displayName)
|
||||
val existing = database.accounts().all().firstOrNull { it.displayName == displayName }
|
||||
|
||||
// ⚠️ Re-authentication, not a duplicate. An account stopped by a 401 has
|
||||
// no other way back: `create` is the only path that writes a credential,
|
||||
// and refusing it here left the user with "that account is already set
|
||||
// up" and no option but to remove the account — discarding the choice to
|
||||
// keep its lists attached. Narrow on purpose: a *healthy* account of the
|
||||
// same name is still a duplicate, so this can never silently overwrite a
|
||||
// working credential.
|
||||
if (existing != null && accountState.needsSignIn(existing.id)) {
|
||||
return@withContext reauthenticate(existing, displayName, username, appPassword, found, selected)
|
||||
}
|
||||
|
||||
if (existing != null) return@withContext Outcome.AlreadyExists
|
||||
|
||||
// ⚠️ In the system, not in Room: an orphan, not a duplicate. `remove()`
|
||||
// ignores whether the AccountManager entry actually went (and `find`
|
||||
// returns null while the device is locked), so this state is reachable —
|
||||
// and refusing here left the user with an account that cannot be removed
|
||||
// from inside the app at all, since the accounts screen is driven off
|
||||
// Room. Clearing it up is kinder than refusing for ever.
|
||||
systemAccount?.let { accounts.remove(it) }
|
||||
|
||||
// ⚠️ Uncancellable as a whole. The row, its lists, the credential and
|
||||
// the system entry are four stores that cannot share a transaction, and
|
||||
// the caller is a viewModelScope tied to the Settings destination — a
|
||||
// back gesture during "Adding the account" would otherwise leave the row
|
||||
// and its lists with no credential and no system account: the rollback
|
||||
// never runs, `needsSignIn` is false so re-auth will not fire, and every
|
||||
// retry answers AlreadyExists. `remove()` documents the same hazard.
|
||||
withContext(NonCancellable) {
|
||||
val inserted = mutableListOf<Long>()
|
||||
val accountId = database.runInTransaction<Long> {
|
||||
val id = database.accounts().insert(
|
||||
AccountEntity(
|
||||
displayName = displayName,
|
||||
// Persist where a 301/308 actually put us — dav4jvm#209 exists
|
||||
// precisely so this is knowable, and re-following the redirect
|
||||
// on every sync is what not persisting it costs.
|
||||
principalUrl = (found.movedTo ?: found.principal).toString(),
|
||||
// The principal's own home set. `resolve("./")` on a collection
|
||||
// URL is a no-op — CalDAV hrefs already end in "/" — so the
|
||||
// old version stored the first collection's own URL, and that
|
||||
// collection may not even be from the account's own home set.
|
||||
homeSetUrl = found.homeSets.firstOrNull()?.toString(),
|
||||
username = username,
|
||||
),
|
||||
)
|
||||
inserted += attach(id, selected)
|
||||
id
|
||||
}
|
||||
|
||||
if (!credentials.put(accountId, appPassword)) {
|
||||
// Never leave a half-made account behind: without a credential it
|
||||
// would sit in Settings failing to sync with nothing to explain it.
|
||||
rollback(accountId, inserted)
|
||||
return@withContext Outcome.CredentialFailed(
|
||||
Outcome.Cause.KEYSTORE_REFUSED,
|
||||
"the device keystore would not store the password",
|
||||
)
|
||||
}
|
||||
|
||||
if (!accounts.add(displayName, accountId)) {
|
||||
credentials.clear(accountId)
|
||||
rollback(accountId, inserted)
|
||||
return@withContext Outcome.AlreadyExists
|
||||
}
|
||||
|
||||
// On the schedule from the moment it exists, and syncing immediately —
|
||||
// an account that shows up empty until the first periodic window looks
|
||||
// broken.
|
||||
syncTrigger.schedule(displayName, syncInterval())
|
||||
syncTrigger.enqueue(displayName)
|
||||
|
||||
Outcome.Created(accountId)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Points [selected] at [accountId], re-attaching what a previous removal
|
||||
* left behind rather than inserting a second copy.
|
||||
*
|
||||
* ⚠️ `remove()` leaves the lists as device-only ones on purpose, so a plain
|
||||
* insert gives the user their old "Personal" full of tasks *and* a freshly
|
||||
* synced "Personal" holding the same tasks from the server. The row keeps
|
||||
* its name, colour, ordering and tasks — the user's, not the server's — and
|
||||
* loses only its cursor, because the account it was reconciled against is
|
||||
* gone.
|
||||
*
|
||||
* ⚠️ On a re-authentication this also has to see the account's *own* lists,
|
||||
* not just detached ones — the re-auth path walks the same picker, so a
|
||||
* lookup that missed them would insert a second copy of every list the
|
||||
* account already syncs. On a create the id was minted a statement earlier,
|
||||
* so nothing is attached to it yet and only detached rows can match.
|
||||
*
|
||||
* @return the ids of the lists this call *created*, which are the only ones
|
||||
* a rollback may delete.
|
||||
*/
|
||||
private fun attach(accountId: Long, selected: Set<TaskCollection>): List<Long> {
|
||||
val known = database.taskLists().attachable(accountId).associateBy { it.href }
|
||||
val inserted = mutableListOf<Long>()
|
||||
selected.forEach { collection ->
|
||||
val href = collection.url.toString()
|
||||
val current = known[href]
|
||||
if (current == null) {
|
||||
inserted += database.taskLists().insert(
|
||||
TaskListEntity(
|
||||
name = collection.displayName ?: collection.url.pathSegments
|
||||
.lastOrNull { it.isNotEmpty() }
|
||||
.orEmpty(),
|
||||
color = collection.color ?: DEFAULT_LIST_COLOR,
|
||||
accountId = accountId,
|
||||
isReadOnly = collection.readOnly,
|
||||
href = href,
|
||||
),
|
||||
)
|
||||
} else {
|
||||
// Changing hands, as opposed to re-authenticating the account
|
||||
// that already owns it: a cursor from the previous owner says
|
||||
// nothing about this one, while the current owner's is still
|
||||
// good and throwing it away costs a full reconciliation.
|
||||
val changingHands = current.accountId != accountId
|
||||
database.taskLists().update(
|
||||
current.copy(
|
||||
accountId = accountId,
|
||||
isReadOnly = collection.readOnly,
|
||||
syncToken = current.syncToken.takeUnless { changingHands },
|
||||
ctag = current.ctag.takeUnless { changingHands },
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
return inserted
|
||||
}
|
||||
|
||||
/** What the server offers an existing account, beside what it already syncs. */
|
||||
sealed interface Collections {
|
||||
data class Found(
|
||||
val collections: List<TaskCollection>,
|
||||
/** Hrefs of the collections this account already syncs. */
|
||||
val attached: Set<String>,
|
||||
) : Collections
|
||||
|
||||
/** The account is stopped, or has no credential we can use. */
|
||||
data object NeedsSignIn : Collections
|
||||
|
||||
data class Failed(val cause: CalDavDiscovery.Outcome.Cause?) : Collections
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-runs discovery for [accountId], so lists added on the server after
|
||||
* setup can be picked up and ones that are synced can be dropped.
|
||||
*/
|
||||
suspend fun collections(accountId: Long): Collections = withContext(io) {
|
||||
if (accountState.needsSignIn(accountId)) return@withContext Collections.NeedsSignIn
|
||||
val account = database.accounts().account(accountId)
|
||||
?: return@withContext Collections.Failed(null)
|
||||
val username = account.username ?: return@withContext Collections.NeedsSignIn
|
||||
val principal = account.principalUrl?.toHttpUrlOrNull()
|
||||
?: return@withContext Collections.Failed(null)
|
||||
val password = (credentials.get(accountId) as? CredentialStore.Secret.Present)?.value
|
||||
?: return@withContext Collections.NeedsSignIn
|
||||
val attached = database.taskLists().syncedForAccount(accountId)
|
||||
.mapNotNull { it.href }
|
||||
.toSet()
|
||||
when (
|
||||
val outcome = gateway.discover(
|
||||
principal.toString(),
|
||||
CalDavGateway.Credentials(username, password, principal),
|
||||
)
|
||||
) {
|
||||
is CalDavDiscovery.Outcome.Found -> Collections.Found(outcome.collections, attached)
|
||||
is CalDavDiscovery.Outcome.NeedsAuthentication,
|
||||
CalDavDiscovery.Outcome.Unauthenticated,
|
||||
-> Collections.NeedsSignIn
|
||||
is CalDavDiscovery.Outcome.NotCalDav -> Collections.Failed(outcome.cause)
|
||||
is CalDavDiscovery.Outcome.Failed -> Collections.Failed(outcome.cause)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes [selected] the synced subset of [offered] for [accountId].
|
||||
*
|
||||
* Newly ticked collections are attached and synced. Unticked ones that were
|
||||
* synced go the way an account removal takes its lists: detached as
|
||||
* device-only lists when [keepUnticked], deleted from this device otherwise.
|
||||
* Nothing is deleted on the server either way.
|
||||
*/
|
||||
suspend fun setSyncedCollections(
|
||||
accountId: Long,
|
||||
offered: List<TaskCollection>,
|
||||
selected: Set<HttpUrl>,
|
||||
keepUnticked: Boolean,
|
||||
) = withContext(io) {
|
||||
val account = database.accounts().account(accountId) ?: return@withContext
|
||||
withContext(NonCancellable) {
|
||||
val change = collectionChange(
|
||||
offered,
|
||||
selected,
|
||||
database.taskLists().syncedForAccount(accountId),
|
||||
)
|
||||
database.runInTransaction {
|
||||
attach(accountId, change.attach)
|
||||
change.drop.forEach { list ->
|
||||
if (keepUnticked) {
|
||||
database.taskLists().setAccount(list.id, null)
|
||||
} else {
|
||||
database.taskLists().delete(list.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
val dropped = change.drop.map { it.id }.toSet()
|
||||
runCatching { push.forgetLists(accountId, dropped) }
|
||||
cadence.forget(dropped)
|
||||
quarantine.forget(dropped)
|
||||
change.drop.forEach { notices.forgetList(accountId, it.name) }
|
||||
if (change.attach.isNotEmpty()) syncTrigger.enqueue(account.displayName, expedited = true)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gives a quarantined resource another go: its failure count is cleared and
|
||||
* the account synced. If it fails again it is counted afresh, and the notice
|
||||
* comes back once it reaches the threshold again.
|
||||
*/
|
||||
suspend fun retryQuarantined(accountId: Long, key: String) = withContext(io) {
|
||||
val account = database.accounts().account(accountId) ?: return@withContext
|
||||
val listIds = database.taskLists().syncedForAccount(accountId).map { it.id }.toSet()
|
||||
quarantine.release(listIds, key)
|
||||
notices.forgetQuarantined(accountId, key)
|
||||
syncTrigger.enqueue(account.displayName, expedited = true)
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces the credential of an account the server had stopped accepting.
|
||||
*
|
||||
* The tasks stay exactly as they are — the password was the only thing that
|
||||
* went stale. Everything the user was asked for on the way here is applied
|
||||
* all the same:
|
||||
*
|
||||
* ⚠️ This branch used to take [appPassword] and drop [found] and [selected]
|
||||
* on the floor. The user walked the whole add flow, ticked collections, and
|
||||
* was shown Done — while no newly-ticked list was created, no unticked one
|
||||
* was detached, and a moved principal or a corrected username was discarded,
|
||||
* so a relocated account could never be repaired. It also never called
|
||||
* `accounts.add`, so an account the user had deleted in Android Settings —
|
||||
* nothing listens for `LOGIN_ACCOUNTS_CHANGED` — stayed absent from Settings
|
||||
* for ever while syncing happily via WorkManager, and every later add
|
||||
* answered AlreadyExists.
|
||||
*/
|
||||
private suspend fun reauthenticate(
|
||||
existing: AccountEntity,
|
||||
displayName: String,
|
||||
username: String,
|
||||
appPassword: String,
|
||||
found: CalDavDiscovery.Outcome.Found,
|
||||
selected: Set<TaskCollection>,
|
||||
): Outcome {
|
||||
val accountId = existing.id
|
||||
if (!credentials.put(accountId, appPassword)) {
|
||||
return Outcome.CredentialFailed(
|
||||
Outcome.Cause.KEYSTORE_REFUSED,
|
||||
"the device keystore would not store the password",
|
||||
)
|
||||
}
|
||||
withContext(NonCancellable) {
|
||||
database.runInTransaction {
|
||||
database.accounts().update(
|
||||
existing.copy(
|
||||
// Where discovery just found it, which is the only way a
|
||||
// principal that has moved can ever be corrected.
|
||||
principalUrl = (found.movedTo ?: found.principal).toString(),
|
||||
homeSetUrl = found.homeSets.firstOrNull()?.toString()
|
||||
?: existing.homeSetUrl,
|
||||
username = username,
|
||||
),
|
||||
)
|
||||
attach(accountId, selected)
|
||||
}
|
||||
// ⚠️ Ticked lists are attached; unticked ones are left alone. The
|
||||
// picker pre-ticks everything *writable*, not everything already
|
||||
// attached, so detaching what is unticked would silently stop
|
||||
// syncing a read-only share the account has synced for months —
|
||||
// over a default the user never chose. Detaching belongs here the
|
||||
// day the picker knows what this account already holds.
|
||||
// The Room row is the account as far as this app is concerned, so a
|
||||
// missing system entry is re-registered rather than left behind.
|
||||
if (accounts.find(displayName) == null) accounts.add(displayName, accountId)
|
||||
accountState.setNeedsSignIn(accountId, false)
|
||||
database.accounts().recordSync(accountId, at = null, error = null)
|
||||
syncTrigger.schedule(displayName, syncInterval())
|
||||
syncTrigger.enqueue(displayName)
|
||||
}
|
||||
return Outcome.Created(accountId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Undoes a half-made account.
|
||||
*
|
||||
* ⚠️ The lists this attempt *created* have to go explicitly.
|
||||
* `task_lists.account_id` is `ON DELETE SET NULL` — deliberately, so
|
||||
* removing a working account never destroys tasks — which means deleting the
|
||||
* account row alone would leave a set of empty device-only lists behind.
|
||||
*
|
||||
* ⚠️ And only those. A list [attach] re-attached was already on the device
|
||||
* and holds the user's tasks; `SET NULL` returns it to being device-only,
|
||||
* which is exactly where it came from.
|
||||
*/
|
||||
private fun rollback(accountId: Long, inserted: List<Long>) = database.runInTransaction {
|
||||
inserted.forEach { database.taskLists().delete(it) }
|
||||
database.accounts().delete(accountId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Puts every existing account back on the periodic schedule.
|
||||
*
|
||||
* Cheap and idempotent — `KEEP` means an already-scheduled account is left
|
||||
* exactly as it is — so calling it on app open costs nothing and repairs the
|
||||
* one case WorkManager cannot: a schedule lost to "clear app data" or to a
|
||||
* restore onto a device that never ran the account-add flow.
|
||||
*/
|
||||
suspend fun rescheduleAll(intervalChanged: Boolean = false) = withContext(io) {
|
||||
val stopped = accountState.needingSignIn()
|
||||
database.accounts().all().forEach { account ->
|
||||
// ⚠️ A stopped account must not come back on the timer. `KEEP` only
|
||||
// keeps work that is unfinished, and CANCELLED counts as finished —
|
||||
// so rescheduling would re-enqueue the very request a 401 removed,
|
||||
// and the next app open would put a dead app password back on a
|
||||
// four-hour loop against a server that throttles by IP.
|
||||
//
|
||||
// This is also where the cancellation happens at all: the engine
|
||||
// cannot cancel from inside the worker it is running in.
|
||||
if (account.id in stopped) {
|
||||
syncTrigger.cancel(account.displayName)
|
||||
} else {
|
||||
syncTrigger.schedule(account.displayName, syncInterval(), intervalChanged)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The accounts a caller may sync right now — stopped ones excluded. */
|
||||
suspend fun syncable(): List<AccountEntity> = withContext(io) {
|
||||
val stopped = accountState.needingSignIn()
|
||||
database.accounts().all().filterNot { it.id in stopped }
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes an account and everything that keys off it.
|
||||
*
|
||||
* The lists are **not** deleted: `task_lists.account_id` is `ON DELETE SET
|
||||
* NULL`, so they become device-only lists. Removing an account is not an
|
||||
* instruction to destroy the tasks it held.
|
||||
*/
|
||||
suspend fun remove(accountId: Long, displayName: String, deleteLocalData: Boolean = false) =
|
||||
withContext(io) {
|
||||
syncTrigger.cancel(displayName)
|
||||
// Before the revocation: the subscriptions can only be removed with
|
||||
// the credential that is about to stop working.
|
||||
withContext(NonCancellable) { runCatching { push.forgetAccount(accountId) } }
|
||||
revokeAppPassword(accountId)
|
||||
|
||||
// ⚠️ Uncancellable from here. Everything below is destructive and
|
||||
// spread over four stores that cannot share a transaction, and the
|
||||
// caller is a viewModelScope tied to the Settings destination — the
|
||||
// user taps Remove, the screen slides away, and a couple of back
|
||||
// gestures kill the scope mid-sequence. Only the DataStore writes can
|
||||
// observe cancellation (every Room DAO here is blocking), so the
|
||||
// realistic landing point is `cadence.forget`: the app password is
|
||||
// already revoked server-side while the row survives holding it, and
|
||||
// the account reads "sign in again" for a credential we ourselves
|
||||
// invalidated. Land further in and the tasks are gone with the row
|
||||
// still there. The tail is three DataStore writes, two deletes and an
|
||||
// AccountManager call — bounded and sub-second, so finishing it is
|
||||
// strictly better than stopping anywhere inside it.
|
||||
withContext(NonCancellable) {
|
||||
// The lists survive as device-only lists, so their cursors must
|
||||
// not: a re-added account would otherwise inherit a "reconciled
|
||||
// recently" that was true of a different account's data.
|
||||
val listIds = database.taskLists().syncedForAccount(accountId)
|
||||
.map { it.id }
|
||||
.toSet()
|
||||
cadence.forget(listIds)
|
||||
// ⚠️ And the quarantine counts, which are keyed the same way and
|
||||
// are just as global. A list re-attached to a new account would
|
||||
// otherwise inherit them, and a resource already at THRESHOLD is
|
||||
// skipped for ever — it never succeeds, so it never clears.
|
||||
quarantine.forget(listIds)
|
||||
// Play's Account Deletion policy does not apply to us — there is
|
||||
// no Agendula account to delete — but "I want it gone from this
|
||||
// device too" is a reasonable thing to want, and it is the only
|
||||
// way to get the tasks off the device without also uninstalling.
|
||||
if (deleteLocalData) database.taskLists().deleteForAccount(accountId)
|
||||
|
||||
accountState.setNeedsSignIn(accountId, false)
|
||||
// Keyed by account id, exactly like the flag above, and just as
|
||||
// orphaned once the row goes: ids are AUTOINCREMENT so they are
|
||||
// never reused, but nothing would ever read or clear these again.
|
||||
notices.dismiss(accountId)
|
||||
// The same reasoning, for what the server said it would let us
|
||||
// create: keyed by an id nothing will ever mention again.
|
||||
collectionSupport.forget(accountId)
|
||||
credentials.clear(accountId)
|
||||
database.accounts().delete(accountId)
|
||||
accounts.find(displayName)?.let { accounts.remove(it) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Best effort, and before the credential is cleared — it is the credential.
|
||||
*
|
||||
* A failure here is never allowed to stop the removal: the user asked for the
|
||||
* account to go, and a server that is unreachable, or was never a Nextcloud,
|
||||
* is not a reason to keep it.
|
||||
*/
|
||||
private suspend fun revokeAppPassword(accountId: Long) {
|
||||
val account = database.accounts().account(accountId) ?: return
|
||||
val username = account.username ?: return
|
||||
val origin = account.principalUrl?.toHttpUrlOrNull() ?: return
|
||||
val password = (credentials.get(accountId) as? CredentialStore.Secret.Present)?.value
|
||||
?: return
|
||||
// ⚠️ The budget lives on the request itself, in AppPassword.revoke.
|
||||
// Wrapping this in withTimeoutOrNull only *looked* bounded: the call
|
||||
// parks on a socket read that no cancellation can break, and withContext
|
||||
// returns when its block does, so the deadline passed and we waited
|
||||
// anyway — minutes, on a multi-homed host that stalls.
|
||||
try {
|
||||
gateway.revokeAppPassword(
|
||||
CalDavGateway.Credentials(username, password, origin),
|
||||
)
|
||||
} catch (_: CancellationException) {
|
||||
// Deliberately swallowed. If the caller went away mid-revoke we still
|
||||
// want the removal to finish rather than stop half-done; the tail
|
||||
// below runs uncancellable for the same reason.
|
||||
}
|
||||
}
|
||||
|
||||
/** What [setSyncedCollections] has to do: collections to attach, lists to let go. */
|
||||
internal data class CollectionChange(
|
||||
val attach: Set<TaskCollection>,
|
||||
val drop: List<TaskListEntity>,
|
||||
)
|
||||
|
||||
private fun sameServer(account: AccountEntity, found: CalDavDiscovery.Outcome.Found): Boolean {
|
||||
val stored = account.principalUrl?.toHttpUrlOrNull() ?: return true
|
||||
return stored.host.equals((found.movedTo ?: found.principal).host, ignoreCase = true)
|
||||
}
|
||||
|
||||
internal companion object {
|
||||
/** M3 primary-ish blue; the user recolours a list from its own screen. */
|
||||
const val DEFAULT_LIST_COLOR = 0xFF4C6FFF.toInt()
|
||||
|
||||
/**
|
||||
* Only collections the server still offers are judged. A synced list
|
||||
* missing from [offered] — a share revoked a minute ago, a flaky listing —
|
||||
* is not the user unticking it, and is left for sync to sort out.
|
||||
*/
|
||||
fun collectionChange(
|
||||
offered: List<TaskCollection>,
|
||||
selected: Set<HttpUrl>,
|
||||
synced: List<TaskListEntity>,
|
||||
): CollectionChange {
|
||||
val syncedHrefs = synced.mapNotNull { it.href }.toSet()
|
||||
val offeredHrefs = offered.map { it.url.toString() }.toSet()
|
||||
val selectedHrefs = selected.map { it.toString() }.toSet()
|
||||
return CollectionChange(
|
||||
attach = offered.filter {
|
||||
it.url in selected && it.url.toString() !in syncedHrefs
|
||||
}.toSet(),
|
||||
drop = synced.filter { it.href in offeredHrefs && it.href !in selectedHrefs },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
package de.jeanlucmakiola.agendula.data.sync
|
||||
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.stringSetPreferencesKey
|
||||
import de.jeanlucmakiola.agendula.data.di.SyncStateDataStore
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.map
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Which accounts the server has stopped accepting.
|
||||
*
|
||||
* Separate from `accounts.last_sync_error` because the two mean different
|
||||
* things to the user and to the engine: an error is "this did not work, we will
|
||||
* try again", while this is "**we have stopped trying** and only you can change
|
||||
* that". Conflating them is how a client ends up retrying a revoked app password
|
||||
* on a timer.
|
||||
*
|
||||
* Lives with the other per-device sync state, and is therefore excluded from
|
||||
* backup — see [SyncStateDataStore]. That is also correct on its own terms: the
|
||||
* credential does not survive a restore either, so a restored "needs sign-in" is
|
||||
* at best redundant and at worst stale.
|
||||
*/
|
||||
@Singleton
|
||||
class AccountStateStore @Inject constructor(
|
||||
@SyncStateDataStore private val dataStore: DataStore<Preferences>,
|
||||
) {
|
||||
|
||||
suspend fun needingSignIn(): Set<Long> = observeNeedingSignIn().first()
|
||||
|
||||
/** Observed, so a 401 during a background sync reaches an open screen. */
|
||||
fun observeNeedingSignIn(): Flow<Set<Long>> = dataStore.data.map { prefs ->
|
||||
prefs[KEY].orEmpty().mapNotNull { it.toLongOrNull() }.toSet()
|
||||
}
|
||||
|
||||
suspend fun needsSignIn(accountId: Long): Boolean = accountId in needingSignIn()
|
||||
|
||||
suspend fun setNeedsSignIn(accountId: Long, needed: Boolean) {
|
||||
dataStore.edit { prefs ->
|
||||
val current = prefs[KEY].orEmpty().toMutableSet()
|
||||
if (needed) current += accountId.toString() else current -= accountId.toString()
|
||||
prefs[KEY] = current
|
||||
// A later stop is news again.
|
||||
if (!needed) prefs[NOTIFIED] = prefs[NOTIFIED].orEmpty() - accountId.toString()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Records that the user has been told [accountId] needs signing in to.
|
||||
*
|
||||
* @return true the first time per stop, so the notification is posted once
|
||||
* rather than on every run the stopped account refuses.
|
||||
*/
|
||||
suspend fun markSignInNotified(accountId: Long): Boolean {
|
||||
var fresh = false
|
||||
dataStore.edit { prefs ->
|
||||
val notified = prefs[NOTIFIED].orEmpty()
|
||||
fresh = accountId.toString() !in notified
|
||||
if (fresh) prefs[NOTIFIED] = notified + accountId.toString()
|
||||
}
|
||||
return fresh
|
||||
}
|
||||
|
||||
private companion object {
|
||||
val KEY = stringSetPreferencesKey("accounts_needing_sign_in")
|
||||
val NOTIFIED = stringSetPreferencesKey("accounts_sign_in_notified")
|
||||
}
|
||||
}
|
||||
@@ -1,103 +0,0 @@
|
||||
package de.jeanlucmakiola.agendula.data.sync
|
||||
|
||||
import android.accounts.Account
|
||||
import android.accounts.AccountManager
|
||||
import android.content.ContentResolver
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import de.jeanlucmakiola.agendula.BuildConfig
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Identifiers shared between Kotlin and the two XML descriptors.
|
||||
*
|
||||
* Derived from `applicationId`, so the debug and `releaseTest` builds get their
|
||||
* own account type and authority and can be installed alongside the real app
|
||||
* without their accounts colliding. ⚠️ `res/xml/authenticator.xml` and
|
||||
* `res/xml/sync_adapter.xml` cannot read `BuildConfig`, so they use string
|
||||
* resources generated by `resValue` in `app/build.gradle.kts` — the two must be
|
||||
* changed together.
|
||||
*/
|
||||
object SyncContract {
|
||||
val ACCOUNT_TYPE: String = BuildConfig.APPLICATION_ID + ".caldav"
|
||||
val AUTHORITY: String = BuildConfig.APPLICATION_ID + ".sync"
|
||||
}
|
||||
|
||||
/**
|
||||
* Agendula's CalDAV accounts, as the system sees them.
|
||||
*
|
||||
* The Room `accounts` table is the source of truth for everything about an
|
||||
* account; this is only the system-visible half — the entry in Settings, and the
|
||||
* handle the sync framework needs to trigger us.
|
||||
*/
|
||||
@Singleton
|
||||
class CalDavAccounts @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
) {
|
||||
|
||||
private val accountManager get() = AccountManager.get(context)
|
||||
|
||||
fun all(): List<Account> =
|
||||
accountManager.getAccountsByType(SyncContract.ACCOUNT_TYPE).toList()
|
||||
|
||||
fun find(name: String): Account? = all().firstOrNull { it.name == name }
|
||||
|
||||
/**
|
||||
* Registers [name] with the system and turns sync on for it.
|
||||
*
|
||||
* No password is handed to `AccountManager`: it stores them as plain `TEXT`.
|
||||
* The app password goes to [CredentialStore], keyed by the Room account id.
|
||||
*
|
||||
* @return false if an account with this name already exists
|
||||
*/
|
||||
fun add(name: String, roomAccountId: Long): Boolean {
|
||||
val account = Account(name, SyncContract.ACCOUNT_TYPE)
|
||||
val userData = Bundle().apply { putString(KEY_ROOM_ACCOUNT_ID, roomAccountId.toString()) }
|
||||
if (!accountManager.addAccountExplicitly(account, null, userData)) return false
|
||||
|
||||
// All three are among the calls that return silently with no registered
|
||||
// sync adapter — see SyncAdapterService. They work because we register one.
|
||||
ContentResolver.setIsSyncable(account, SyncContract.AUTHORITY, 1)
|
||||
ContentResolver.setSyncAutomatically(account, SyncContract.AUTHORITY, true)
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* The Room account id for [account], or null.
|
||||
*
|
||||
* ⚠️ `getUserData` returns null while the device is locked, so a
|
||||
* boot-triggered sync has to wait for unlock rather than treat this as
|
||||
* "account gone".
|
||||
*/
|
||||
fun roomAccountId(account: Account): Long? =
|
||||
accountManager.getUserData(account, KEY_ROOM_ACCOUNT_ID)?.toLongOrNull()
|
||||
|
||||
/**
|
||||
* Removes the system-visible account.
|
||||
*
|
||||
* `removeAccountExplicitly` works because we own the account type. The Room
|
||||
* row and the credential are removed by [AccountRepository]; pruning must be
|
||||
* driven by this call and by `AccountManager`'s account-removed broadcast,
|
||||
* never by "absent from the visible set" — `getAccountsByType` returns
|
||||
* nothing while the device is locked, and treating that as removal is how a
|
||||
* restore silently deletes the user's lists.
|
||||
*/
|
||||
fun remove(account: Account): Boolean = accountManager.removeAccountExplicitly(account)
|
||||
|
||||
fun requestSync(account: Account) {
|
||||
ContentResolver.requestSync(
|
||||
account,
|
||||
SyncContract.AUTHORITY,
|
||||
Bundle().apply {
|
||||
putBoolean(ContentResolver.SYNC_EXTRAS_MANUAL, true)
|
||||
putBoolean(ContentResolver.SYNC_EXTRAS_EXPEDITED, true)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val KEY_ROOM_ACCOUNT_ID = "roomAccountId"
|
||||
}
|
||||
}
|
||||
@@ -1,127 +0,0 @@
|
||||
package de.jeanlucmakiola.agendula.data.sync
|
||||
|
||||
import de.jeanlucmakiola.agendula.data.di.IoDispatcher
|
||||
import de.jeanlucmakiola.caldav.AppPassword
|
||||
import de.jeanlucmakiola.caldav.CalDavDiscovery
|
||||
import de.jeanlucmakiola.caldav.CalDavHttp
|
||||
import de.jeanlucmakiola.caldav.DnsJavaResolver
|
||||
import de.jeanlucmakiola.caldav.NextcloudLoginFlow
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.HttpUrl
|
||||
import java.util.concurrent.TimeUnit
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* The network side of adding an account, behind one interface.
|
||||
*
|
||||
* It exists so the sign-in state machine can be tested. That machine decides
|
||||
* which of five outcomes leads where, when a one-shot app password is spent, and
|
||||
* which host a credential is scoped to — all of which are exactly the sort of
|
||||
* thing that goes wrong quietly, and none of which should require a server to
|
||||
* exercise.
|
||||
*/
|
||||
interface CalDavGateway {
|
||||
|
||||
/** Discovery against [target], optionally carrying credentials. */
|
||||
suspend fun discover(target: String, credentials: Credentials? = null): CalDavDiscovery.Outcome
|
||||
|
||||
/** Starts Nextcloud Login Flow v2, or returns null if this is not a Nextcloud. */
|
||||
suspend fun startLoginFlow(server: HttpUrl): NextcloudLoginFlow.Flow?
|
||||
|
||||
suspend fun pollLoginFlow(flow: NextcloudLoginFlow.Flow): NextcloudLoginFlow.PollResult
|
||||
|
||||
/**
|
||||
* Hands an app password back to the server, best effort.
|
||||
*
|
||||
* Without it, uninstalling never revokes anything — the credential we minted
|
||||
* outlives the app in the user's device list.
|
||||
*/
|
||||
suspend fun revokeAppPassword(credentials: Credentials): Boolean
|
||||
|
||||
/**
|
||||
* The same, for a password the login flow just minted.
|
||||
*
|
||||
* ⚠️ Separate because [Credentials.origin] means something different here:
|
||||
* the *server root* the flow reported, not a principal URL. Sending it
|
||||
* through [revokeAppPassword] would derive the OCS root as though it were a
|
||||
* principal, and a subpath install's `https://host/nextcloud/` would collapse
|
||||
* to `https://host/` — a DELETE that 404s on every one of them.
|
||||
*/
|
||||
suspend fun revokeIssuedAppPassword(credentials: Credentials): Boolean
|
||||
|
||||
/** Credentials, and the origin whose registrable domain they are scoped to. */
|
||||
data class Credentials(val username: String, val password: String, val origin: HttpUrl)
|
||||
}
|
||||
|
||||
@Singleton
|
||||
class OkHttpCalDavGateway @Inject constructor(
|
||||
@IoDispatcher private val io: CoroutineDispatcher,
|
||||
) : CalDavGateway {
|
||||
|
||||
/**
|
||||
* Becomes the app password's **name** in Nextcloud's Settings → Security →
|
||||
* Devices & sessions. OkHttp's default would show `okhttp/4.12.0`, leaving
|
||||
* the user unable to tell what to revoke — which defeats the whole point of
|
||||
* using an app password.
|
||||
*/
|
||||
private val userAgent = "Agendula (Android)"
|
||||
|
||||
override suspend fun discover(
|
||||
target: String,
|
||||
credentials: CalDavGateway.Credentials?,
|
||||
): CalDavDiscovery.Outcome = withContext(io) {
|
||||
val client = credentials?.let {
|
||||
CalDavHttp.authenticated(userAgent, it.username, it.password, it.origin)
|
||||
} ?: CalDavHttp.anonymous(userAgent)
|
||||
CalDavDiscovery(client, DnsJavaResolver()).discover(target)
|
||||
}
|
||||
|
||||
override suspend fun startLoginFlow(server: HttpUrl): NextcloudLoginFlow.Flow? =
|
||||
withContext(io) {
|
||||
NextcloudLoginFlow(CalDavHttp.anonymous(userAgent), userAgent)
|
||||
.start(server, now())
|
||||
.getOrNull()
|
||||
}
|
||||
|
||||
/**
|
||||
* ⚠️ The budget lives on the request, as it does for the revocation.
|
||||
* `execute()` parks on a socket read that no cancellation can break, so the
|
||||
* four places that cancel the poll job only stop the *next* request — and
|
||||
* the shared client's ceiling is sized for a multiget, not for a two-second
|
||||
* poll loop against a server that answered a moment ago.
|
||||
*/
|
||||
override suspend fun pollLoginFlow(flow: NextcloudLoginFlow.Flow): NextcloudLoginFlow.PollResult =
|
||||
withContext(io) {
|
||||
val client = CalDavHttp.anonymous(userAgent).newBuilder()
|
||||
.callTimeout(POLL_TIMEOUT_SECONDS, TimeUnit.SECONDS)
|
||||
.build()
|
||||
NextcloudLoginFlow(client, userAgent).poll(flow, now())
|
||||
}
|
||||
|
||||
override suspend fun revokeAppPassword(
|
||||
credentials: CalDavGateway.Credentials,
|
||||
): Boolean = withContext(io) {
|
||||
val client = CalDavHttp.authenticated(
|
||||
userAgent, credentials.username, credentials.password, credentials.origin,
|
||||
)
|
||||
AppPassword.revoke(client, credentials.origin)
|
||||
}
|
||||
|
||||
override suspend fun revokeIssuedAppPassword(
|
||||
credentials: CalDavGateway.Credentials,
|
||||
): Boolean = withContext(io) {
|
||||
val client = CalDavHttp.authenticated(
|
||||
userAgent, credentials.username, credentials.password, credentials.origin,
|
||||
)
|
||||
AppPassword.revokeAt(client, credentials.origin)
|
||||
}
|
||||
|
||||
private fun now() = System.currentTimeMillis() / 1000
|
||||
|
||||
private companion object {
|
||||
/** One poll of a 2s loop. Long enough for a homelab, short enough to cancel. */
|
||||
const val POLL_TIMEOUT_SECONDS = 15L
|
||||
}
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
package de.jeanlucmakiola.agendula.data.sync
|
||||
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.stringSetPreferencesKey
|
||||
import de.jeanlucmakiola.agendula.data.di.SyncStateDataStore
|
||||
import de.jeanlucmakiola.caldav.CollectionSupport
|
||||
import kotlinx.coroutines.flow.first
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* What each account's home set answered to OPTIONS, last time we asked.
|
||||
*
|
||||
* ⚠️ A cache, never the answer. The "new task list" affordance is *hidden*
|
||||
* where neither MKCALENDAR nor extended
|
||||
* MKCOL exists, and a picker that has to make a network round trip before it can
|
||||
* draw a row is a picker that stutters — so the last answer is what it draws
|
||||
* with, and [RemoteListRepository] re-asks before it actually writes. A server
|
||||
* that gained the capability in an upgrade, or lost it in a config change, is
|
||||
* then wrong for exactly one glance rather than for ever.
|
||||
*/
|
||||
@Singleton
|
||||
class CollectionSupportStore @Inject constructor(
|
||||
@SyncStateDataStore private val dataStore: DataStore<Preferences>,
|
||||
) {
|
||||
|
||||
suspend fun get(accountId: Long): CollectionSupport =
|
||||
decode(dataStore.data.first()[KEY].orEmpty())[accountId] ?: CollectionSupport.NONE
|
||||
|
||||
/** Asks [ask], records what it said, and hands it back. */
|
||||
suspend fun refresh(accountId: Long, ask: () -> CollectionSupport): CollectionSupport {
|
||||
val answer = ask()
|
||||
dataStore.edit { prefs ->
|
||||
// Re-read inside `edit`, which DataStore serialises: two accounts
|
||||
// can be asked at once and a snapshot taken outside would drop one.
|
||||
val current = decode(prefs[KEY].orEmpty()).toMutableMap()
|
||||
current[accountId] = answer
|
||||
prefs[KEY] = current.map { (id, support) -> encode(id, support) }.toSet()
|
||||
}
|
||||
return answer
|
||||
}
|
||||
|
||||
suspend fun forget(accountId: Long) {
|
||||
dataStore.edit { prefs ->
|
||||
prefs[KEY] = decode(prefs[KEY].orEmpty())
|
||||
.filterKeys { it != accountId }
|
||||
.map { (id, support) -> encode(id, support) }
|
||||
.toSet()
|
||||
}
|
||||
}
|
||||
|
||||
private fun encode(accountId: Long, support: CollectionSupport): String =
|
||||
"$accountId|${support.mkCalendar}|${support.extendedMkCol}"
|
||||
|
||||
private fun decode(entries: Set<String>): Map<Long, CollectionSupport> =
|
||||
entries.mapNotNull { entry ->
|
||||
val parts = entry.split('|')
|
||||
if (parts.size != FIELDS) return@mapNotNull null
|
||||
val accountId = parts[0].toLongOrNull() ?: return@mapNotNull null
|
||||
accountId to CollectionSupport(
|
||||
mkCalendar = parts[1].toBooleanStrictOrNull() ?: return@mapNotNull null,
|
||||
extendedMkCol = parts[2].toBooleanStrictOrNull() ?: return@mapNotNull null,
|
||||
)
|
||||
}.toMap()
|
||||
|
||||
private companion object {
|
||||
val KEY = stringSetPreferencesKey("collection_support")
|
||||
const val FIELDS = 3
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,171 +0,0 @@
|
||||
package de.jeanlucmakiola.agendula.data.sync
|
||||
|
||||
import android.security.keystore.KeyGenParameterSpec
|
||||
import android.security.keystore.KeyPermanentlyInvalidatedException
|
||||
import android.security.keystore.KeyProperties
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import de.jeanlucmakiola.agendula.data.di.CredentialsDataStore
|
||||
import kotlinx.coroutines.flow.first
|
||||
import java.io.IOException
|
||||
import java.security.GeneralSecurityException
|
||||
import java.security.ProviderException
|
||||
import java.security.KeyStore
|
||||
import java.util.Base64
|
||||
import javax.crypto.AEADBadTagException
|
||||
import javax.crypto.Cipher
|
||||
import javax.crypto.KeyGenerator
|
||||
import javax.crypto.SecretKey
|
||||
import javax.crypto.spec.GCMParameterSpec
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* App passwords, encrypted with a hardware-backed Keystore key.
|
||||
*
|
||||
* `androidx.security:security-crypto` is **formally deprecated and terminal** —
|
||||
* deprecated at 1.1.0-alpha07, shipped deprecated in stable 1.1.0, with release
|
||||
* notes saying there will be no further releases — and its successor
|
||||
* `datastore-tink` is alpha. So: Keystore `AES/GCM/NoPadding` directly, blob in
|
||||
* DataStore.
|
||||
*
|
||||
* Be honest about what this buys. `AccountManager` stores passwords as plain
|
||||
* `TEXT` — there is no encryption or hashing anywhere in AOSP — so file-based
|
||||
* encryption plus a same-signature check is the whole boundary there. That is
|
||||
* DAVx5's posture and it is defensible, but it is not secure storage. This is
|
||||
* better, and the difference is worth the ~80 lines.
|
||||
*
|
||||
* Three deliberate non-choices:
|
||||
* - `setUserAuthenticationRequired` is left at its default of `false`. Requiring
|
||||
* a device unlock per decryption makes background sync impossible.
|
||||
* - `setUnlockedDeviceRequired` is **not** set, for the same reason.
|
||||
* - A failure to decrypt is *never* a crash. It means re-authenticate.
|
||||
*/
|
||||
@Singleton
|
||||
class CredentialStore @Inject constructor(
|
||||
@CredentialsDataStore private val dataStore: DataStore<Preferences>,
|
||||
) {
|
||||
|
||||
/** What came back for an account. */
|
||||
sealed interface Secret {
|
||||
data class Present(val value: String) : Secret
|
||||
|
||||
data object Absent : Secret
|
||||
|
||||
/**
|
||||
* The ciphertext exists but can no longer be decrypted, so the only
|
||||
* recovery is to sign in again.
|
||||
*
|
||||
* Reached by a restored backup (Keystore keys are non-exportable, so a
|
||||
* restored blob is permanently undecryptable — which is why the blob is
|
||||
* excluded from backup), by the key being invalidated when the user
|
||||
* changes their lock screen, or by corruption.
|
||||
*/
|
||||
data class Unrecoverable(val reason: String) : Secret
|
||||
}
|
||||
|
||||
/**
|
||||
* Stores [appPassword] for [accountId].
|
||||
*
|
||||
* @return false when the Keystore could not be used at all. A wedged or
|
||||
* degraded keystore throws [ProviderException], which is a `RuntimeException`
|
||||
* and would otherwise take down the account-add flow — the same "never
|
||||
* crash over this" rule [get] follows.
|
||||
*/
|
||||
suspend fun put(accountId: Long, appPassword: String): Boolean = try {
|
||||
val cipher = Cipher.getInstance(TRANSFORMATION).apply { init(Cipher.ENCRYPT_MODE, key()) }
|
||||
// The IV travels with the ciphertext. GCM requires a unique IV per
|
||||
// encryption under the same key; letting the provider generate it is the
|
||||
// only way to be sure of that.
|
||||
val payload = cipher.iv + cipher.doFinal(appPassword.toByteArray(Charsets.UTF_8))
|
||||
dataStore.edit { it[keyFor(accountId)] = Base64.getEncoder().encodeToString(payload) }
|
||||
true
|
||||
} catch (e: GeneralSecurityException) {
|
||||
false
|
||||
} catch (e: ProviderException) {
|
||||
false
|
||||
} catch (e: IOException) {
|
||||
false
|
||||
}
|
||||
|
||||
suspend fun get(accountId: Long): Secret {
|
||||
val stored = dataStore.data.first()[keyFor(accountId)] ?: return Secret.Absent
|
||||
return try {
|
||||
val payload = Base64.getDecoder().decode(stored)
|
||||
val cipher = Cipher.getInstance(TRANSFORMATION).apply {
|
||||
init(
|
||||
Cipher.DECRYPT_MODE,
|
||||
key(),
|
||||
GCMParameterSpec(TAG_BITS, payload, 0, IV_BYTES),
|
||||
)
|
||||
}
|
||||
Secret.Present(
|
||||
String(
|
||||
cipher.doFinal(payload, IV_BYTES, payload.size - IV_BYTES),
|
||||
Charsets.UTF_8,
|
||||
),
|
||||
)
|
||||
} catch (e: KeyPermanentlyInvalidatedException) {
|
||||
// The lock screen changed, or the key was otherwise invalidated.
|
||||
Secret.Unrecoverable(e.message ?: "the encryption key was invalidated")
|
||||
} catch (e: AEADBadTagException) {
|
||||
// Wrong key or tampered ciphertext — the restored-backup case.
|
||||
Secret.Unrecoverable(e.message ?: "the stored credential could not be decrypted")
|
||||
} catch (e: GeneralSecurityException) {
|
||||
Secret.Unrecoverable(e.message ?: "the stored credential could not be read")
|
||||
} catch (e: IllegalArgumentException) {
|
||||
// Not valid Base64 at all — a truncated or hand-edited blob.
|
||||
Secret.Unrecoverable(e.message ?: "the stored credential is malformed")
|
||||
} catch (e: ProviderException) {
|
||||
// ⚠️ AndroidKeyStore signals keystore-level failure ("Keystore
|
||||
// operation failed", "Failed to load key") with this — a
|
||||
// RuntimeException, so none of the catches above match it. On a
|
||||
// device with a degraded keystore it would crash the sync worker
|
||||
// instead of prompting a re-authentication.
|
||||
Secret.Unrecoverable(e.message ?: "the device keystore is unavailable")
|
||||
} catch (e: IOException) {
|
||||
// KeyStore.load declares it.
|
||||
Secret.Unrecoverable(e.message ?: "the device keystore could not be opened")
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun clear(accountId: Long) {
|
||||
dataStore.edit { it.remove(keyFor(accountId)) }
|
||||
}
|
||||
|
||||
/** Every stored credential. Used when the last account goes away. */
|
||||
suspend fun clearAll() {
|
||||
dataStore.edit { it.clear() }
|
||||
}
|
||||
|
||||
private fun keyFor(accountId: Long) = stringPreferencesKey("caldav_app_password_$accountId")
|
||||
|
||||
private fun key(): SecretKey {
|
||||
val keyStore = KeyStore.getInstance(KEYSTORE).apply { load(null) }
|
||||
(keyStore.getEntry(KEY_ALIAS, null) as? KeyStore.SecretKeyEntry)?.let { return it.secretKey }
|
||||
|
||||
return KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, KEYSTORE).apply {
|
||||
init(
|
||||
KeyGenParameterSpec.Builder(
|
||||
KEY_ALIAS,
|
||||
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT,
|
||||
)
|
||||
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
|
||||
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
|
||||
// Not calling setUserAuthenticationRequired /
|
||||
// setUnlockedDeviceRequired is the point — see the class doc.
|
||||
.build(),
|
||||
)
|
||||
}.generateKey()
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val KEYSTORE = "AndroidKeyStore"
|
||||
const val KEY_ALIAS = "agendula.caldav.credentials"
|
||||
const val TRANSFORMATION = "AES/GCM/NoPadding"
|
||||
const val IV_BYTES = 12
|
||||
const val TAG_BITS = 128
|
||||
}
|
||||
}
|
||||
@@ -1,145 +0,0 @@
|
||||
package de.jeanlucmakiola.agendula.data.sync
|
||||
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.longPreferencesKey
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import de.jeanlucmakiola.agendula.data.di.SyncStateDataStore
|
||||
import de.jeanlucmakiola.caldav.NextcloudLoginFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Where a started login flow is written down, so it can outlive this process.
|
||||
*
|
||||
* A seam for the same reason [AccountCreator] and [CalDavGateway] are: the flow
|
||||
* that decides *when* a one-shot password stops being ours has to be testable
|
||||
* without a DataStore.
|
||||
*/
|
||||
interface LoginFlowRecord {
|
||||
|
||||
/** Called **before** the browser is handed the URL. */
|
||||
suspend fun remember(flow: NextcloudLoginFlow.Flow)
|
||||
|
||||
/** The flow is over, however it ended. */
|
||||
suspend fun forget()
|
||||
}
|
||||
|
||||
/**
|
||||
* The Nextcloud login flow that is currently out at a browser.
|
||||
*
|
||||
* ⚠️ [NextcloudLoginFlow.Flow]'s own doc says to persist it **before** launching
|
||||
* the browser, because the flow outlives our process — and it did not. The
|
||||
* browser is a separate task, so process death while the user is approving is
|
||||
* ordinary rather than exotic, and it stranded a one-shot app password that
|
||||
* nothing could then collect *or* revoke: the flow's poll token was the only way
|
||||
* back to it, and it lived in a ViewModel field.
|
||||
*
|
||||
* The token is not a credential. It authorises exactly one poll of one flow the
|
||||
* user is in the middle of approving, and it is useless past the twenty-minute
|
||||
* window — so it belongs in the sync-state store rather than the Keystore.
|
||||
*
|
||||
* ⚠️ What this does **not** cover is the window *after* approval, where the
|
||||
* password itself lives only in memory. That needs the wizard's own state to
|
||||
* survive, which is a different piece of work.
|
||||
*/
|
||||
@Singleton
|
||||
class PendingLoginFlowStore @Inject constructor(
|
||||
@SyncStateDataStore private val dataStore: DataStore<Preferences>,
|
||||
private val gateway: CalDavGateway,
|
||||
) : LoginFlowRecord {
|
||||
|
||||
private val lock = Mutex()
|
||||
private var reclaimed = false
|
||||
|
||||
/** Records [flow] so a process that dies mid-approval can still finish with it. */
|
||||
override suspend fun remember(flow: NextcloudLoginFlow.Flow) {
|
||||
dataStore.edit { prefs ->
|
||||
prefs[LOGIN_URL] = flow.loginUrl.toString()
|
||||
prefs[POLL_ENDPOINT] = flow.pollEndpoint.toString()
|
||||
prefs[POLL_TOKEN] = flow.pollToken
|
||||
prefs[DEADLINE] = flow.deadlineEpochSeconds
|
||||
}
|
||||
}
|
||||
|
||||
/** The flow is finished, one way or another. */
|
||||
override suspend fun forget() {
|
||||
dataStore.edit { prefs ->
|
||||
prefs.remove(LOGIN_URL)
|
||||
prefs.remove(POLL_ENDPOINT)
|
||||
prefs.remove(POLL_TOKEN)
|
||||
prefs.remove(DEADLINE)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Collects and hands back a password nobody is left to own.
|
||||
*
|
||||
* Revoked rather than used: the address the user typed, the collections they
|
||||
* ticked and the account name are all gone with the process, so there is
|
||||
* nothing to finish. What is left is a live app password in the user's
|
||||
* device list, under the same name as every other attempt — which is exactly
|
||||
* what they cannot tell apart, and so dare not prune.
|
||||
*
|
||||
* ⚠️ **Once per process.** This activity is recreated on every rotation,
|
||||
* theme switch and locale change, and a second run against a flow the *live*
|
||||
* wizard is still polling would consume its one-shot 200 and revoke the
|
||||
* password it was about to be handed. A flow remembered after this has run
|
||||
* belongs to a wizard that is alive to finish it.
|
||||
*/
|
||||
suspend fun reclaim() {
|
||||
lock.withLock {
|
||||
if (reclaimed) return
|
||||
reclaimed = true
|
||||
}
|
||||
val flow = pending() ?: return
|
||||
when (val result = gateway.pollLoginFlow(flow)) {
|
||||
is NextcloudLoginFlow.PollResult.Approved -> {
|
||||
// Cleared first: a revocation that fails must not leave a token
|
||||
// that would be polled again, and the 200 is already spent.
|
||||
forget()
|
||||
gateway.revokeIssuedAppPassword(
|
||||
CalDavGateway.Credentials(
|
||||
username = result.credentials.loginName,
|
||||
password = result.credentials.appPassword,
|
||||
origin = result.credentials.server,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
is NextcloudLoginFlow.PollResult.Expired -> forget()
|
||||
|
||||
// Still inside the window, or the server had a moment. Either way
|
||||
// the token is still worth something, so it is left for the next
|
||||
// open; a poll past the deadline answers Expired and clears it.
|
||||
NextcloudLoginFlow.PollResult.Pending,
|
||||
is NextcloudLoginFlow.PollResult.Failed,
|
||||
-> Unit
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun pending(): NextcloudLoginFlow.Flow? {
|
||||
val prefs = dataStore.data.first()
|
||||
val endpoint = prefs[POLL_ENDPOINT]?.toHttpUrlOrNull() ?: return null
|
||||
val token = prefs[POLL_TOKEN] ?: return null
|
||||
val deadline = prefs[DEADLINE] ?: return null
|
||||
return NextcloudLoginFlow.Flow(
|
||||
loginUrl = prefs[LOGIN_URL]?.toHttpUrlOrNull() ?: endpoint,
|
||||
pollEndpoint = endpoint,
|
||||
pollToken = token,
|
||||
deadlineEpochSeconds = deadline,
|
||||
)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
val LOGIN_URL = stringPreferencesKey("login_flow_url")
|
||||
val POLL_ENDPOINT = stringPreferencesKey("login_flow_poll_endpoint")
|
||||
val POLL_TOKEN = stringPreferencesKey("login_flow_poll_token")
|
||||
val DEADLINE = longPreferencesKey("login_flow_deadline")
|
||||
}
|
||||
}
|
||||
@@ -1,116 +0,0 @@
|
||||
package de.jeanlucmakiola.agendula.data.sync
|
||||
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.stringSetPreferencesKey
|
||||
import de.jeanlucmakiola.agendula.data.di.SyncStateDataStore
|
||||
import kotlinx.coroutines.flow.first
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* How many times each resource has failed, and therefore which ones to skip.
|
||||
*
|
||||
* ⚠️ Deliberately **not** a backoff. A backoff assumes the failure is transient
|
||||
* and asks "how long until I try again"; the failures that matter here are
|
||||
* permanent — a body sabre answers 415 for, a contradictory `RRULE`/`EXDATE`
|
||||
* pair Nextcloud answers 500 for forever, a 507 the spec forbids retrying at
|
||||
* all. The question worth asking is "how many times before I leave this one
|
||||
* alone and finish the collection", and the answer is [THRESHOLD].
|
||||
*
|
||||
* Counts are cleared the moment a resource succeeds, so a genuinely transient
|
||||
* failure costs nothing beyond the runs it actually failed in.
|
||||
*/
|
||||
@Singleton
|
||||
class QuarantineStore @Inject constructor(
|
||||
@SyncStateDataStore private val dataStore: DataStore<Preferences>,
|
||||
) {
|
||||
|
||||
/** Current failure counts, keyed by [key]. */
|
||||
suspend fun counts(): Map<String, Int> = decode(dataStore.data.first()[KEY].orEmpty())
|
||||
|
||||
private fun decode(entries: Set<String>): Map<String, Int> = entries.mapNotNull { entry ->
|
||||
val separator = entry.lastIndexOf(COUNT_SEPARATOR)
|
||||
if (separator <= 0) return@mapNotNull null
|
||||
val count = entry.substring(separator + 1).toIntOrNull() ?: return@mapNotNull null
|
||||
entry.substring(0, separator) to count
|
||||
}.toMap()
|
||||
|
||||
/**
|
||||
* Applies one account's changes without disturbing anyone else's.
|
||||
*
|
||||
* ⚠️ Not a whole-map replace. The counts are global — keyed by list, not by
|
||||
* account — while `SyncWorker`'s uniqueness is only *per account*, so two
|
||||
* accounts can sync at once. Each would snapshot the same global map and the
|
||||
* later writer would discard the other's increments and resurrect the
|
||||
* counters it had cleared. Re-reading inside `edit`, which DataStore
|
||||
* serialises, keeps the read-modify-write atomic.
|
||||
*
|
||||
* @param updates counts to set, replacing any current value for those keys.
|
||||
* @param cleared keys to remove outright, whatever they currently hold.
|
||||
*/
|
||||
suspend fun merge(updates: Map<String, Int>, cleared: Set<String>) {
|
||||
dataStore.edit { prefs ->
|
||||
val current = decode(prefs[KEY].orEmpty()).toMutableMap()
|
||||
current -= cleared
|
||||
current += updates.filterValues { it > 0 }
|
||||
prefs[KEY] = current
|
||||
.map { (key, count) -> "$key$COUNT_SEPARATOR$count" }
|
||||
.toSet()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Forgets every count belonging to [listIds].
|
||||
*
|
||||
* ⚠️ The keys are global, exactly like the cadence cursors cleared beside
|
||||
* them. A list detached from a removed account and re-attached to a new one
|
||||
* would otherwise inherit its old counters — and a resource already at
|
||||
* [THRESHOLD] is skipped for ever, since a quarantined resource never
|
||||
* succeeds and so never clears.
|
||||
*/
|
||||
suspend fun forget(listIds: Set<Long>) {
|
||||
if (listIds.isEmpty()) return
|
||||
val prefixes = listIds.map { "$it|" }
|
||||
dataStore.edit { prefs ->
|
||||
val current = decode(prefs[KEY].orEmpty())
|
||||
.filterKeys { key -> prefixes.none(key::startsWith) }
|
||||
prefs[KEY] = current
|
||||
.map { (key, count) -> "$key$COUNT_SEPARATOR$count" }
|
||||
.toSet()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the count for one resource in any of [listIds], so the next sync
|
||||
* tries it again — the user's "retry" on a quarantined task.
|
||||
*/
|
||||
suspend fun release(listIds: Set<Long>, href: String) {
|
||||
val keys = listIds.map { key(it, href) }.toSet()
|
||||
dataStore.edit { prefs ->
|
||||
val current = decode(prefs[KEY].orEmpty())
|
||||
if (current.keys.none { it in keys }) return@edit
|
||||
prefs[KEY] = (current - keys)
|
||||
.map { (key, count) -> "$key$COUNT_SEPARATOR$count" }
|
||||
.toSet()
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Attempts before a resource is left alone.
|
||||
*
|
||||
* Three rather than one: a 502 from a reverse proxy mid-restart and a
|
||||
* permanently malformed body arrive as the same outcome, and burning two
|
||||
* extra runs is cheaper than quarantining a resource that would have
|
||||
* worked.
|
||||
*/
|
||||
const val THRESHOLD = 3
|
||||
|
||||
fun key(listId: Long, href: String) = "$listId|$href"
|
||||
|
||||
private const val COUNT_SEPARATOR = '#'
|
||||
private val KEY = stringSetPreferencesKey("sync_quarantine")
|
||||
}
|
||||
}
|
||||
@@ -1,312 +0,0 @@
|
||||
package de.jeanlucmakiola.agendula.data.sync
|
||||
|
||||
import de.jeanlucmakiola.agendula.data.di.IoDispatcher
|
||||
import de.jeanlucmakiola.agendula.data.sync.push.PushStore
|
||||
import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver
|
||||
import de.jeanlucmakiola.agendula.data.tasks.StorageMode
|
||||
import de.jeanlucmakiola.agendula.data.tasks.room.AccountEntity
|
||||
import de.jeanlucmakiola.agendula.data.tasks.room.TaskListEntity
|
||||
import de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase
|
||||
import de.jeanlucmakiola.caldav.CalDavHttp
|
||||
import de.jeanlucmakiola.caldav.CollectionAdmin
|
||||
import de.jeanlucmakiola.caldav.CollectionOutcome
|
||||
import de.jeanlucmakiola.caldav.CollectionSupport
|
||||
import de.jeanlucmakiola.caldav.DavCollectionAdmin
|
||||
import de.jeanlucmakiola.caldav.ResourceNames
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.NonCancellable
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import okhttp3.OkHttpClient
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Task lists that live on a server: making them, renaming them, recolouring
|
||||
* them and deleting them.
|
||||
*
|
||||
* ⚠️ Every write here is **server first, Room second**, and that ordering is the
|
||||
* whole design. The other way round gives the user a list that exists on their
|
||||
* phone and nowhere else, and nothing to tell them so — `task_lists.is_dirty`
|
||||
* was already set by a rename and read by nobody, which is precisely that
|
||||
* failure with the evidence discarded. A refused write leaves the local row
|
||||
* exactly as it was, so what is on screen is what is on the server.
|
||||
*
|
||||
* Device-only lists are not this class's business: they have no href, no
|
||||
* account and nothing to ask permission of. [de.jeanlucmakiola.agendula.data.tasks.TasksRepository]
|
||||
* keeps them.
|
||||
*/
|
||||
@Singleton
|
||||
class RemoteListRepository @Inject constructor(
|
||||
private val database: TasksDatabase,
|
||||
private val credentials: CredentialStore,
|
||||
private val support: CollectionSupportStore,
|
||||
private val syncTrigger: SyncTrigger,
|
||||
private val cadence: SyncCadenceStore,
|
||||
private val notices: SyncNoticeStore,
|
||||
private val quarantine: QuarantineStore,
|
||||
private val accountState: AccountStateStore,
|
||||
private val resolver: ProviderResolver,
|
||||
private val push: PushStore,
|
||||
@IoDispatcher private val io: CoroutineDispatcher,
|
||||
) {
|
||||
|
||||
/** Why a collection write did not happen, in a form the UI can translate. */
|
||||
sealed interface Outcome {
|
||||
data object Done : Outcome
|
||||
|
||||
/** The server said no, and will say no again. */
|
||||
data class Refused(val code: Int) : Outcome
|
||||
|
||||
/** The server could not be reached. Worth another try. */
|
||||
data object Unreachable : Outcome
|
||||
|
||||
/** This account cannot make collections at all — iCloud, Posteo, Google. */
|
||||
data object Unsupported : Outcome
|
||||
|
||||
/** Ours is a read-only share; the write belongs to whoever owns it. */
|
||||
data object ReadOnly : Outcome
|
||||
|
||||
/** The account is gone, stopped, or has no credential we can decrypt. */
|
||||
data object NoAccount : Outcome
|
||||
|
||||
/**
|
||||
* The server answered something this call cannot make sense of.
|
||||
*
|
||||
* ⚠️ Not [Unreachable]. `CollectionOutcome` is one type across create,
|
||||
* update and delete, so each of them has branches the other's method
|
||||
* can return and its own cannot — and mapping those to [Unreachable]
|
||||
* told someone sitting on wifi that they were offline. Unreachable is a
|
||||
* claim about the network, and this is not one.
|
||||
*/
|
||||
data object Unexpected : Outcome
|
||||
}
|
||||
|
||||
/**
|
||||
* The accounts a new list may be created on, freshest answer first.
|
||||
*
|
||||
* ⚠️ Re-asked rather than cached for ever. `CollectionSupportStore` holds
|
||||
* the last answer so a picker can draw immediately, but a server that gained
|
||||
* the capability in an upgrade — or lost it in a config change — must be
|
||||
* able to say so, and the only moment that costs nothing is while the user
|
||||
* is looking at the picker.
|
||||
*/
|
||||
suspend fun creatableAccounts(): List<AccountEntity> = withContext(io) {
|
||||
// ⚠️ Empty in External mode, whatever the accounts table holds. The
|
||||
// lists on screen then come from a third-party provider, so a row
|
||||
// inserted into ours would exist, sync, and be visible to nobody.
|
||||
if (resolver.mode() != StorageMode.OWN) return@withContext emptyList()
|
||||
val stopped = accountState.needingSignIn()
|
||||
val candidates = database.accounts().all().filter {
|
||||
it.homeSetUrl?.toHttpUrlOrNull() != null && it.id !in stopped
|
||||
}
|
||||
// ⚠️ Together, not one after another. Each probe is a blocking OPTIONS,
|
||||
// so three accounts with one server on a slow link held the "Where" row
|
||||
// off the sheet for the sum of all three — with the sheet already drawn.
|
||||
coroutineScope {
|
||||
candidates.map { account -> async { account to supportFor(account) } }
|
||||
.awaitAll()
|
||||
.filter { (_, support) -> support.canCreate }
|
||||
.map { (account, _) -> account }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes a collection on [accountId]'s home set and a row pointing at it.
|
||||
*
|
||||
* @return the new list's local id, or why there is none.
|
||||
*/
|
||||
suspend fun create(
|
||||
accountId: Long,
|
||||
name: String,
|
||||
color: Int,
|
||||
): Outcome = withContext(io) {
|
||||
// ⚠️ Re-checked here, not only in `creatableAccounts`. The picker's list
|
||||
// is a StateFlow that outlives one opening of the sheet, so a mode that
|
||||
// flips between the list being built and Save being tapped would
|
||||
// otherwise create the collection on the server and file the row in a
|
||||
// store External mode never reads.
|
||||
if (resolver.mode() != StorageMode.OWN) return@withContext Outcome.NoAccount
|
||||
val account = database.accounts().account(accountId) ?: return@withContext Outcome.NoAccount
|
||||
val homeSet = account.homeSetUrl?.toHttpUrlOrNull()
|
||||
?: return@withContext Outcome.NoAccount
|
||||
val admin = adminFor(account) ?: return@withContext Outcome.NoAccount
|
||||
val capabilities = support.refresh(accountId) { admin.support(homeSet) }
|
||||
if (!capabilities.canCreate) return@withContext Outcome.Unsupported
|
||||
|
||||
// ⚠️ A second attempt, but only for the one refusal a different name
|
||||
// can fix. Nextcloud's trashbin *renames* a deleted collection rather
|
||||
// than removing it, so re-creating under a segment used before answers
|
||||
// 403 for ever — and "Shopping" is exactly the name someone deletes and
|
||||
// remakes. Retrying anything else spends a second authenticated write
|
||||
// that will fail the same way, and worse: a 401 is a second hit on the
|
||||
// brute-force counter, and a 507 retried reports the wrong code back,
|
||||
// since the caller only ever sees the *last* attempt's.
|
||||
val first = ResourceNames.forCollection(name)
|
||||
var created = admin.create(homeSet, first, name, color, capabilities)
|
||||
if (created is CollectionOutcome.Refused && created.code in NAME_REFUSALS) {
|
||||
created = admin.create(homeSet, ResourceNames.randomCollection(), name, color, capabilities)
|
||||
}
|
||||
|
||||
when (created) {
|
||||
is CollectionOutcome.Created -> {
|
||||
// ⚠️ Uncancellable. The collection exists on the server from
|
||||
// here on, and a cancellation between that and the row would
|
||||
// leave one the app has no record of and no way to reach —
|
||||
// visible only on the next full account re-add.
|
||||
withContext(NonCancellable) {
|
||||
database.taskLists().insert(
|
||||
TaskListEntity(
|
||||
name = name,
|
||||
color = color,
|
||||
accountId = accountId,
|
||||
href = created.url.toString(),
|
||||
),
|
||||
)
|
||||
}
|
||||
// The server has it and we do not; a sync is how the two agree
|
||||
// on a ctag and a token rather than reconciling in full later.
|
||||
syncTrigger.enqueue(account.displayName, expedited = true)
|
||||
Outcome.Done
|
||||
}
|
||||
|
||||
is CollectionOutcome.Refused -> Outcome.Refused(created.code)
|
||||
is CollectionOutcome.Failed -> Outcome.Unreachable
|
||||
CollectionOutcome.Unsupported -> Outcome.Unsupported
|
||||
CollectionOutcome.Updated -> Outcome.Unexpected
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Renames and recolours [listId] on the server, then locally.
|
||||
*
|
||||
* ⚠️ Refuses a read-only collection rather than discovering it at write
|
||||
* time. A share the owner has made read-only answers 403 to a PROPPATCH, and
|
||||
* a row that has already been renamed locally by then reads as a rename that
|
||||
* worked and then quietly reverted on the next sync.
|
||||
*/
|
||||
suspend fun rename(listId: Long, name: String, color: Int): Outcome = withContext(io) {
|
||||
val list = database.taskLists().entity(listId) ?: return@withContext Outcome.NoAccount
|
||||
if (list.isReadOnly) return@withContext Outcome.ReadOnly
|
||||
val url = list.href?.toHttpUrlOrNull() ?: return@withContext Outcome.NoAccount
|
||||
val account = list.accountId?.let { database.accounts().account(it) }
|
||||
?: return@withContext Outcome.NoAccount
|
||||
val admin = adminFor(account) ?: return@withContext Outcome.NoAccount
|
||||
|
||||
when (val outcome = admin.updateProperties(url, displayName = name, color = color)) {
|
||||
CollectionOutcome.Updated -> {
|
||||
withContext(NonCancellable) {
|
||||
// Read again inside the write: a sync running alongside this
|
||||
// may have refreshed the ACL flag or the cursor, and writing
|
||||
// back the entity we read before the network call would
|
||||
// revert it.
|
||||
val current = database.taskLists().entity(listId) ?: return@withContext
|
||||
database.taskLists().update(
|
||||
// isDirty stays false: the server already has this. The
|
||||
// flag existed for a PROPPATCH that never happened.
|
||||
current.copy(name = name, color = color, isDirty = false),
|
||||
)
|
||||
}
|
||||
Outcome.Done
|
||||
}
|
||||
|
||||
is CollectionOutcome.Refused -> Outcome.Refused(outcome.code)
|
||||
is CollectionOutcome.Failed -> Outcome.Unreachable
|
||||
is CollectionOutcome.Created, CollectionOutcome.Unsupported -> Outcome.Unexpected
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes [listId] on the server, then on the device.
|
||||
*
|
||||
* ⚠️ The one write where "already gone" is success — [CollectionAdmin.delete]
|
||||
* grades 404 and 410 that way — because otherwise a collection someone
|
||||
* removed from another client leaves a row here that nothing can get rid of.
|
||||
*/
|
||||
suspend fun delete(listId: Long): Outcome = withContext(io) {
|
||||
val list = database.taskLists().entity(listId) ?: return@withContext Outcome.Done
|
||||
if (list.isReadOnly) return@withContext Outcome.ReadOnly
|
||||
val url = list.href?.toHttpUrlOrNull() ?: return@withContext Outcome.NoAccount
|
||||
val account = list.accountId?.let { database.accounts().account(it) }
|
||||
?: return@withContext Outcome.NoAccount
|
||||
val admin = adminFor(account) ?: return@withContext Outcome.NoAccount
|
||||
|
||||
when (val outcome = admin.delete(url)) {
|
||||
CollectionOutcome.Updated -> {
|
||||
withContext(NonCancellable) {
|
||||
// `tasks.list_id` is ON DELETE CASCADE, so the tasks go with
|
||||
// it — which is what was just done on the server.
|
||||
database.taskLists().delete(listId)
|
||||
// And the per-list state keyed off it, exactly as removing an
|
||||
// account clears its lists': the ids are AUTOINCREMENT so
|
||||
// nothing would ever read these again. The notices go by
|
||||
// *name*, which is how they are keyed — a discarded-edit
|
||||
// notice would otherwise name a list that no longer exists
|
||||
// until the user tapped "Got it".
|
||||
forgetPerListState(listId)
|
||||
list.accountId?.let { notices.forgetList(it, list.name) }
|
||||
}
|
||||
Outcome.Done
|
||||
}
|
||||
|
||||
is CollectionOutcome.Refused -> Outcome.Refused(outcome.code)
|
||||
is CollectionOutcome.Failed -> Outcome.Unreachable
|
||||
is CollectionOutcome.Created, CollectionOutcome.Unsupported -> Outcome.Unexpected
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun supportFor(account: AccountEntity): CollectionSupport {
|
||||
val homeSet = account.homeSetUrl?.toHttpUrlOrNull() ?: return CollectionSupport.NONE
|
||||
val admin = adminFor(account) ?: return CollectionSupport.NONE
|
||||
return support.refresh(account.id) { admin.support(homeSet) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Null when the account has no credential we can use — a stopped account, or
|
||||
* a restore.
|
||||
*
|
||||
* ⚠️ The stopped check is the same one `SyncEngine.sync` makes before it
|
||||
* touches the network, and for the same reason: Nextcloud's brute-force
|
||||
* protection throttles and then **429s per source IP**, so spending a
|
||||
* request on a credential we already know the server rejects lands on the
|
||||
* user's *other* clients. Opening the "new list" sheet must not do that any
|
||||
* more than a timer may.
|
||||
*/
|
||||
private suspend fun adminFor(account: AccountEntity): CollectionAdmin? {
|
||||
if (accountState.needsSignIn(account.id)) return null
|
||||
val username = account.username ?: return null
|
||||
val origin = account.principalUrl?.toHttpUrlOrNull() ?: return null
|
||||
val password = (credentials.get(account.id) as? CredentialStore.Secret.Present)?.value
|
||||
?: return null
|
||||
return DavCollectionAdmin(client(username, password, origin))
|
||||
}
|
||||
|
||||
private fun client(username: String, password: String, origin: HttpUrl): OkHttpClient =
|
||||
CalDavHttp.authenticated(USER_AGENT, username, password, origin)
|
||||
|
||||
private suspend fun forgetPerListState(listId: Long) {
|
||||
val ids = setOf(listId)
|
||||
cadence.forget(ids)
|
||||
quarantine.forget(ids)
|
||||
// The subscription went with the collection on the server.
|
||||
push.forget(ids)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
/** The same agent the sync and the add flow use, so the server names us once. */
|
||||
const val USER_AGENT = "Agendula (Android)"
|
||||
|
||||
/**
|
||||
* Refusals a different path segment can get past, and only those.
|
||||
*
|
||||
* 403 is Nextcloud's trashbin still holding the name; 405 is a server
|
||||
* answering "already a collection there". Everything else — 401, 409,
|
||||
* 423, 507 — means the same thing under any name.
|
||||
*/
|
||||
val NAME_REFUSALS = setOf(403, 405)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package de.jeanlucmakiola.agendula.data.sync
|
||||
|
||||
import de.jeanlucmakiola.agendula.data.tasks.room.AccountEntity
|
||||
|
||||
/**
|
||||
* Task lists that live on a server: making, renaming, recolouring and deleting
|
||||
* them. The `full` flavor talks CalDAV; the `offline` one has no accounts, so
|
||||
* every list is device-only and none of this is ever reached with a real one.
|
||||
*/
|
||||
interface RemoteLists {
|
||||
|
||||
/** Why a collection write did not happen, in a form the UI can translate. */
|
||||
sealed interface Outcome {
|
||||
data object Done : Outcome
|
||||
|
||||
/** The server said no, and will say no again. */
|
||||
data class Refused(val code: Int) : Outcome
|
||||
|
||||
/** The server could not be reached. Worth another try. */
|
||||
data object Unreachable : Outcome
|
||||
|
||||
/** This account cannot make collections at all — iCloud, Posteo, Google. */
|
||||
data object Unsupported : Outcome
|
||||
|
||||
/** Ours is a read-only share; the write belongs to whoever owns it. */
|
||||
data object ReadOnly : Outcome
|
||||
|
||||
/** The account is gone, stopped, or has no credential we can decrypt. */
|
||||
data object NoAccount : Outcome
|
||||
|
||||
/**
|
||||
* The server answered something this call cannot make sense of.
|
||||
*
|
||||
* ⚠️ Not [Unreachable]. `CollectionOutcome` is one type across create,
|
||||
* update and delete, so each of them has branches the other's method
|
||||
* can return and its own cannot — and mapping those to [Unreachable]
|
||||
* told someone sitting on wifi that they were offline. Unreachable is a
|
||||
* claim about the network, and this is not one.
|
||||
*/
|
||||
data object Unexpected : Outcome
|
||||
}
|
||||
|
||||
/** The accounts a new list may be created on. */
|
||||
suspend fun creatableAccounts(): List<AccountEntity>
|
||||
|
||||
/** Makes a collection on [accountId]'s home set and a row pointing at it. */
|
||||
suspend fun create(accountId: Long, name: String, color: Int): Outcome
|
||||
|
||||
suspend fun rename(listId: Long, name: String, color: Int): Outcome
|
||||
|
||||
suspend fun delete(listId: Long): Outcome
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
package de.jeanlucmakiola.agendula.data.sync
|
||||
|
||||
import android.accounts.Account
|
||||
import android.app.Service
|
||||
import android.content.AbstractThreadedSyncAdapter
|
||||
import android.content.ContentProviderClient
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.SyncResult
|
||||
import android.os.Bundle
|
||||
import android.os.IBinder
|
||||
import androidx.work.WorkInfo
|
||||
import androidx.work.WorkManager
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import kotlin.time.Duration.Companion.minutes
|
||||
|
||||
/**
|
||||
* The sync adapter whose entire job is to start a WorkManager job and wait.
|
||||
*
|
||||
* DAVx5's own comment describes the same design: *"We use the sync adapter
|
||||
* framework only for the trigger, actual syncing is implemented with
|
||||
* WorkManager."*
|
||||
*
|
||||
* ⚠️ Registering this is **not optional decoration**.
|
||||
* `ContentService.hasAuthorityAccess()` gates `requestSync`,
|
||||
* `setSyncAutomatically`, `addPeriodicSync`, `setIsSyncable`, `getSyncStatus` and
|
||||
* seven more behind a compat change that is on for targetSdk ≥ 34 — which we
|
||||
* are. With no sync adapter registered for our authority, every one of those
|
||||
* calls **returns silently**: no exception, no log, and it passes on a
|
||||
* Robolectric shadow. The visible result is an account permanently reading "Sync
|
||||
* off for all items" with a greyed-out "Sync now", and it is documented on no
|
||||
* Android behaviour-changes page.
|
||||
*
|
||||
* The greying-out is why the app ships its own sync button regardless:
|
||||
* `enabledSyncNowMenu()` needs at least one checked authority switch, and ours
|
||||
* is `userVisible="false"`.
|
||||
*/
|
||||
class SyncAdapterService : Service() {
|
||||
|
||||
private val adapter by lazy { CalDavSyncAdapter(applicationContext) }
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder = adapter.syncAdapterBinder
|
||||
}
|
||||
|
||||
private class CalDavSyncAdapter(context: Context) :
|
||||
AbstractThreadedSyncAdapter(context, /* autoInitialize = */ true) {
|
||||
|
||||
override fun onPerformSync(
|
||||
account: Account,
|
||||
extras: Bundle,
|
||||
authority: String,
|
||||
provider: ContentProviderClient,
|
||||
syncResult: SyncResult,
|
||||
) {
|
||||
val workManager = WorkManager.getInstance(context)
|
||||
val uniqueName = SyncTrigger(context).enqueue(account.name)
|
||||
|
||||
// Block this thread until the work reaches a terminal state. The framework
|
||||
// treats onPerformSync returning as "the sync is done", so returning early
|
||||
// would make every sync look instantaneous and defeat the back-off it
|
||||
// applies on failure. runBlocking is fine here: onPerformSync is already
|
||||
// called on a background thread the framework owns.
|
||||
//
|
||||
// ⚠️ Watch the **unique work name**, not the request id. enqueueUniqueWork
|
||||
// is asynchronous — the WorkSpec row is not written by the time the next
|
||||
// line runs — so a flow keyed on the id emits null for an unknown id and
|
||||
// the wait returns immediately, having waited for nothing. And under
|
||||
// KEEP, when a run is already in flight, our request is never enqueued at
|
||||
// all and its id stays unknown forever. Keying on the name handles both:
|
||||
// it waits for whichever run is actually happening.
|
||||
val infos = runCatching {
|
||||
runBlocking {
|
||||
withTimeoutOrNull(WORKER_TIMEOUT_MINUTES.minutes) {
|
||||
workManager.getWorkInfosForUniqueWorkFlow(uniqueName)
|
||||
.first { infos -> infos.isNotEmpty() && infos.all { it.state.isFinished } }
|
||||
}
|
||||
}
|
||||
}.getOrNull()
|
||||
|
||||
// Counted as a soft error: the engine's own per-collection and
|
||||
// per-resource isolation decides what is actually fatal, and telling the
|
||||
// framework otherwise would have it back off the whole account. Being
|
||||
// deduplicated by KEEP is *not* a failure — the sync is happening, this
|
||||
// trigger simply joined the one already running.
|
||||
val timedOut = infos == null
|
||||
val failed = infos?.any { it.state == WorkInfo.State.FAILED } == true
|
||||
if (timedOut || failed) syncResult.stats.numIoExceptions++
|
||||
}
|
||||
|
||||
private companion object {
|
||||
/** DAVx5 uses the same ceiling; an ordinary worker is documented for < 10 min. */
|
||||
const val WORKER_TIMEOUT_MINUTES = 10L
|
||||
}
|
||||
}
|
||||
@@ -1,109 +0,0 @@
|
||||
package de.jeanlucmakiola.agendula.data.sync
|
||||
|
||||
import android.accounts.AbstractAccountAuthenticator
|
||||
import android.accounts.Account
|
||||
import android.accounts.AccountAuthenticatorResponse
|
||||
import android.accounts.AccountManager
|
||||
import android.app.Service
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.os.IBinder
|
||||
|
||||
/**
|
||||
* The account authenticator.
|
||||
*
|
||||
* Agendula holds no auth tokens — a CalDAV account is a username and an app
|
||||
* password, and the password lives in [CredentialStore], not here.
|
||||
* `AccountManager` stores passwords as plain `TEXT`; there is no encryption or
|
||||
* hashing anywhere in AOSP, so nothing secret is handed to it.
|
||||
*
|
||||
* It is **not** required by any
|
||||
* provider — that argument was circular. The real reasons: a stable account
|
||||
* identity a third-party engine could address, presence in system Settings, and
|
||||
* the sync framework as a change trigger.
|
||||
*/
|
||||
class SyncAuthenticator(private val context: Context) : AbstractAccountAuthenticator(context) {
|
||||
|
||||
/**
|
||||
* ⚠️ Refuses until the account-add UI exists.
|
||||
*
|
||||
* The authenticator service is exported and registered, so Settings →
|
||||
* Accounts → Add account lists Agendula **today**. Handing back an intent to
|
||||
* a screen that does not yet handle [ACTION_ADD_ACCOUNT] would open the
|
||||
* ordinary home screen while Settings waits forever on a response nothing
|
||||
* answers. A refusal the user can read is strictly better than a hang; 2d
|
||||
* replaces this with the real intent and answers [response].
|
||||
*/
|
||||
override fun addAccount(
|
||||
response: AccountAuthenticatorResponse?,
|
||||
accountType: String?,
|
||||
authTokenType: String?,
|
||||
requiredFeatures: Array<out String>?,
|
||||
options: Bundle?,
|
||||
): Bundle = unsupported("Add a CalDAV account from inside Agendula, under Settings")
|
||||
|
||||
override fun editProperties(
|
||||
response: AccountAuthenticatorResponse?,
|
||||
accountType: String?,
|
||||
): Bundle = Bundle()
|
||||
|
||||
/**
|
||||
* ⚠️ Never `null`. `AbstractAccountAuthenticator.Transport` reads a null
|
||||
* return as "I will answer asynchronously via the response", and nothing here
|
||||
* ever does — the caller's `AccountManagerFuture` would never complete.
|
||||
*/
|
||||
override fun confirmCredentials(
|
||||
response: AccountAuthenticatorResponse?,
|
||||
account: Account?,
|
||||
options: Bundle?,
|
||||
): Bundle = unsupported("Agendula does not confirm credentials from the system UI")
|
||||
|
||||
/** No token type: this is Basic/Digest against a CalDAV server. */
|
||||
override fun getAuthToken(
|
||||
response: AccountAuthenticatorResponse?,
|
||||
account: Account?,
|
||||
authTokenType: String?,
|
||||
options: Bundle?,
|
||||
): Bundle = unsupported("Agendula accounts do not use auth tokens")
|
||||
|
||||
override fun getAuthTokenLabel(authTokenType: String?): String? = null
|
||||
|
||||
/** Never `null`, for the reason given on [confirmCredentials]. */
|
||||
override fun updateCredentials(
|
||||
response: AccountAuthenticatorResponse?,
|
||||
account: Account?,
|
||||
authTokenType: String?,
|
||||
options: Bundle?,
|
||||
): Bundle = unsupported("Re-authenticate from inside Agendula, under Settings")
|
||||
|
||||
override fun hasFeatures(
|
||||
response: AccountAuthenticatorResponse?,
|
||||
account: Account?,
|
||||
features: Array<out String>?,
|
||||
): Bundle = Bundle().apply { putBoolean(AccountManager.KEY_BOOLEAN_RESULT, false) }
|
||||
|
||||
private fun unsupported(message: String) = Bundle().apply {
|
||||
putInt(AccountManager.KEY_ERROR_CODE, AccountManager.ERROR_CODE_UNSUPPORTED_OPERATION)
|
||||
putString(AccountManager.KEY_ERROR_MESSAGE, message)
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** Sent to `MainActivity` when the system asks us to add an account (chunk 2d). */
|
||||
const val ACTION_ADD_ACCOUNT = "de.jeanlucmakiola.agendula.ADD_ACCOUNT"
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Binds [SyncAuthenticator] for the system.
|
||||
*
|
||||
* Exported and guarded by `android.permission.ACCOUNT_MANAGER` — note that
|
||||
* `android.permission.ACCOUNT_AUTHENTICATOR`, which the obvious guess would
|
||||
* reach for, **does not exist**.
|
||||
*/
|
||||
class AuthenticatorService : Service() {
|
||||
|
||||
private val authenticator by lazy { SyncAuthenticator(this) }
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? = authenticator.iBinder
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
package de.jeanlucmakiola.agendula.data.sync
|
||||
|
||||
import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver
|
||||
import de.jeanlucmakiola.agendula.data.tasks.StorageMode
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.map
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Whether CalDAV accounts can do anything right now.
|
||||
*
|
||||
* Sync writes into Agendula's own store. In External mode the screens read a
|
||||
* third-party provider instead, so an account would sync into rows nobody sees.
|
||||
* Read from the stored preference rather than [ProviderResolver.mode], which is
|
||||
* only current once `StorageModeHolder` has mirrored it — and a worker can start
|
||||
* before that.
|
||||
*/
|
||||
@Singleton
|
||||
class SyncAvailability @Inject constructor(
|
||||
private val prefs: SettingsPrefs,
|
||||
private val resolver: ProviderResolver,
|
||||
) {
|
||||
|
||||
suspend fun accountsUsable(): Boolean = observe().first()
|
||||
|
||||
fun observe(): Flow<Boolean> = prefs.storageMode.map { stored ->
|
||||
(stored ?: resolver.autoMode()) == StorageMode.OWN
|
||||
}
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
package de.jeanlucmakiola.agendula.data.sync
|
||||
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.stringSetPreferencesKey
|
||||
import de.jeanlucmakiola.agendula.data.di.SyncStateDataStore
|
||||
import kotlinx.coroutines.flow.first
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.hours
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
* When each collection was last reconciled against a full listing.
|
||||
*
|
||||
* ⚠️ This is the mitigation for the one RFC 6578 failure that has no signal at
|
||||
* all: a token the server still accepts, over a change log it has already
|
||||
* pruned, answers `207` with zero changes and no error. Nothing in the protocol
|
||||
* distinguishes that from "nothing happened". The only defence is to stop
|
||||
* trusting the token periodically and diff a real listing — so the full path is
|
||||
* a permanent safety net, not a fallback, and this is its clock.
|
||||
*
|
||||
* Kept out of Room deliberately: it is scheduling bookkeeping, not user data,
|
||||
* and it must never be part of a backup that could restore a stale "we checked
|
||||
* recently" into a fresh install.
|
||||
*/
|
||||
@Singleton
|
||||
class SyncCadenceStore @Inject constructor(
|
||||
@SyncStateDataStore private val dataStore: DataStore<Preferences>,
|
||||
) {
|
||||
|
||||
/**
|
||||
* Last *scheduled* full reconciliation per list id.
|
||||
*
|
||||
* ⚠️ Not "the last time a full listing was read". A collection whose server
|
||||
* has no `sync-collection` support reads one on every run, and recording
|
||||
* each would keep this permanently fresh — so nothing hung off the periodic
|
||||
* mark would ever come due again.
|
||||
*/
|
||||
suspend fun lastFullSync(): Map<Long, Instant> =
|
||||
dataStore.data.first()[KEY].orEmpty().mapNotNull { entry ->
|
||||
val separator = entry.lastIndexOf(SEPARATOR)
|
||||
if (separator <= 0) return@mapNotNull null
|
||||
val id = entry.substring(0, separator).toLongOrNull() ?: return@mapNotNull null
|
||||
val at = entry.substring(separator + 1).toLongOrNull() ?: return@mapNotNull null
|
||||
id to Instant.fromEpochSeconds(at)
|
||||
}.toMap()
|
||||
|
||||
/** Merges, rather than replacing, so concurrent accounts do not erase each other. */
|
||||
suspend fun record(reconciled: Map<Long, Instant>) {
|
||||
if (reconciled.isEmpty()) return
|
||||
dataStore.edit { prefs ->
|
||||
val current = prefs[KEY].orEmpty()
|
||||
.mapNotNull { entry ->
|
||||
val separator = entry.lastIndexOf(SEPARATOR)
|
||||
if (separator <= 0) null else entry.substring(0, separator) to entry
|
||||
}
|
||||
.toMap()
|
||||
.toMutableMap()
|
||||
reconciled.forEach { (id, at) ->
|
||||
current["$id"] = "$id$SEPARATOR${at.epochSeconds}"
|
||||
}
|
||||
prefs[KEY] = current.values.toSet()
|
||||
}
|
||||
}
|
||||
|
||||
/** Forgets a list, so a re-added account starts from a full reconciliation. */
|
||||
suspend fun forget(listIds: Set<Long>) {
|
||||
if (listIds.isEmpty()) return
|
||||
dataStore.edit { prefs ->
|
||||
prefs[KEY] = prefs[KEY].orEmpty().filterNot { entry ->
|
||||
entry.substringBefore(SEPARATOR).toLongOrNull() in listIds
|
||||
}.toSet()
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* How long a sync token is trusted before a full listing is diffed anyway.
|
||||
*
|
||||
* Long enough that the incremental path still carries almost every sync,
|
||||
* short enough that a silently pruned change log is a day's divergence
|
||||
* rather than an indefinite one.
|
||||
*/
|
||||
val FULL_RECONCILIATION_INTERVAL: Duration = 24.hours
|
||||
|
||||
private const val SEPARATOR = '@'
|
||||
private val KEY = stringSetPreferencesKey("sync_last_full")
|
||||
}
|
||||
}
|
||||
@@ -1,257 +0,0 @@
|
||||
package de.jeanlucmakiola.agendula.data.sync
|
||||
|
||||
import de.jeanlucmakiola.agendula.data.di.IoDispatcher
|
||||
import de.jeanlucmakiola.agendula.data.sync.push.PushRegistrar
|
||||
import de.jeanlucmakiola.agendula.data.tasks.room.AccountEntity
|
||||
import de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase
|
||||
import de.jeanlucmakiola.caldav.CalDavHttp
|
||||
import de.jeanlucmakiola.caldav.CalendarCollection
|
||||
import de.jeanlucmakiola.caldav.RemoteCalendar
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Syncs one account: every list it owns, against the collection each points at.
|
||||
*
|
||||
* ⚠️ **A failed collection must not fail the account.** One revoked share, one
|
||||
* calendar the server 500s on, must not stop the other four from syncing — so
|
||||
* every collection's outcome is a [SyncReport] rather than an exception, and the
|
||||
* account's own result is the list of them.
|
||||
*/
|
||||
@Singleton
|
||||
class SyncEngine @Inject constructor(
|
||||
private val database: TasksDatabase,
|
||||
private val store: RoomSyncStore,
|
||||
private val credentials: CredentialStore,
|
||||
private val quarantine: QuarantineStore,
|
||||
private val cadence: SyncCadenceStore,
|
||||
private val accountState: AccountStateStore,
|
||||
private val notices: SyncNoticeStore,
|
||||
private val availability: SyncAvailability,
|
||||
private val push: PushRegistrar,
|
||||
@IoDispatcher private val io: CoroutineDispatcher,
|
||||
) {
|
||||
|
||||
/** Why an account could not be synced at all, as opposed to one of its lists. */
|
||||
sealed interface Result {
|
||||
data class Synced(
|
||||
val reports: List<SyncReport>,
|
||||
/**
|
||||
* What this run destroyed or gave up on that was not already on
|
||||
* record — the caller's cue to say so out loud.
|
||||
*/
|
||||
val notices: List<SyncNotice> = emptyList(),
|
||||
) : Result
|
||||
|
||||
/** The credential is gone or undecryptable: only re-authentication helps. */
|
||||
data class NeedsSignIn(val accountId: Long, val reason: String) : Result
|
||||
|
||||
data class Misconfigured(val reason: String) : Result
|
||||
|
||||
/** External storage mode: nothing reads what a sync would write, so none runs. */
|
||||
data object Paused : Result
|
||||
}
|
||||
|
||||
suspend fun sync(accountName: String): Result = withContext(io) {
|
||||
if (!availability.accountsUsable()) return@withContext Result.Paused
|
||||
val account = database.accounts().all().firstOrNull { it.displayName == accountName }
|
||||
?: return@withContext Result.Misconfigured("no such account: $accountName")
|
||||
|
||||
// ⚠️ Before anything reaches the network. A periodic request that outlives
|
||||
// the stop — or a manual trigger on a stopped account — must not spend a
|
||||
// request on a credential we already know the server rejects: Nextcloud
|
||||
// throttles then 429s per source IP, and that lands on the user's other
|
||||
// clients rather than on us.
|
||||
if (accountState.needsSignIn(account.id)) {
|
||||
return@withContext Result.NeedsSignIn(account.id, "waiting for you to sign in again")
|
||||
}
|
||||
|
||||
val username = account.username
|
||||
?: return@withContext fatal(account.id, Result.Misconfigured("account has no username"))
|
||||
val origin = account.principalUrl?.toHttpUrlOrNull()
|
||||
?: return@withContext fatal(
|
||||
account.id,
|
||||
Result.Misconfigured("account has no principal URL"),
|
||||
)
|
||||
|
||||
val password = when (val secret = credentials.get(account.id)) {
|
||||
is CredentialStore.Secret.Present -> secret.value
|
||||
CredentialStore.Secret.Absent -> {
|
||||
stopForSignIn(account.id, "no stored password")
|
||||
return@withContext Result.NeedsSignIn(account.id, "no stored password")
|
||||
}
|
||||
is CredentialStore.Secret.Unrecoverable -> {
|
||||
stopForSignIn(account.id, secret.reason)
|
||||
return@withContext Result.NeedsSignIn(account.id, secret.reason)
|
||||
}
|
||||
}
|
||||
|
||||
val client = CalDavHttp.authenticated(USER_AGENT, username, password, origin)
|
||||
val subscriptions = push.subscriptionsByHref(account.id)
|
||||
val reports = syncCollections(account) { url ->
|
||||
CalendarCollection(client, url, pushRegistration = subscriptions[url.toString()])
|
||||
}
|
||||
|
||||
// ⚠️ Before the auth check, not after it. A 401 on one collection does
|
||||
// not un-discard an edit another collection already destroyed, and
|
||||
// returning NeedsSignIn past this point would drop the record of it.
|
||||
// Outside `syncCollections` because that is driven without a network by
|
||||
// the reconciliation tests, which have nothing to say about notices.
|
||||
val fresh = notices.record(
|
||||
accountId = account.id,
|
||||
at = kotlin.time.Clock.System.now(),
|
||||
reports = reports,
|
||||
titles = quarantinedTitles(reports),
|
||||
)
|
||||
|
||||
if (reports.any { it.authFailure }) {
|
||||
// ⚠️ Stop the account rather than let the schedule keep trying.
|
||||
// Nextcloud throttles and then 429s **per source IP**, so a timer on a
|
||||
// dead app password degrades every other Nextcloud client on the
|
||||
// user's network — and there is nothing here to retry: the fix is a
|
||||
// sign-in only the user can perform.
|
||||
stopForSignIn(account.id, "the server rejected the credentials")
|
||||
return@withContext Result.NeedsSignIn(account.id, "the server rejected the credentials")
|
||||
}
|
||||
|
||||
accountState.setNeedsSignIn(account.id, false)
|
||||
// Never fails the sync: push is an optimisation on top of the schedule.
|
||||
runCatching { push.onSynced(account, reports) }
|
||||
Result.Synced(reports, fresh)
|
||||
}
|
||||
|
||||
/**
|
||||
* The local title of each quarantined resource, by href.
|
||||
*
|
||||
* ⚠️ Resolved here rather than left to the store, which has no database.
|
||||
* Without it the user is told "a task has stopped syncing" over a row
|
||||
* reading `a1f9c3e2-….ics` — the opaque blob `SyncNoticeStore` refuses to
|
||||
* show for a discarded edit, and unactionable for exactly the same reason.
|
||||
* A resource we have never stored has no title to find, and its filename is
|
||||
* then genuinely all there is.
|
||||
*/
|
||||
private fun quarantinedTitles(reports: List<SyncReport>): Map<String, String> =
|
||||
reports.filter { it.quarantined.isNotEmpty() }
|
||||
.flatMap { report ->
|
||||
val wanted = report.quarantined.mapTo(mutableSetOf()) { it.href }
|
||||
store.rowsIn(report.listId)
|
||||
.filter { it.href in wanted && !it.title.isNullOrBlank() }
|
||||
.map { it.href!! to it.title!! }
|
||||
}
|
||||
.toMap()
|
||||
|
||||
/**
|
||||
* Records why the account could not be synced at all.
|
||||
*
|
||||
* ⚠️ Without this the row keeps its old `lastSyncAt`, and the accounts screen
|
||||
* goes on reporting "synced 5 minutes ago" for an account whose credential
|
||||
* can no longer be decrypted — the silent failure the account layer exists to
|
||||
* avoid.
|
||||
*/
|
||||
/**
|
||||
* Marks an account as stopped until the user signs in again.
|
||||
*
|
||||
* ⚠️ It does **not** cancel the work, even though stopping the timer is the
|
||||
* whole point — because this runs *inside* `SyncWorker`, and one of the two
|
||||
* unique names it would cancel is the WorkSpec currently executing us.
|
||||
* WorkManager would interrupt the coroutine, so `Result.NeedsSignIn` would
|
||||
* never be returned and the adapter would see CANCELLED rather than FAILED.
|
||||
*
|
||||
* The flag does the work instead: [sync] refuses before touching the network,
|
||||
* so a firing that survives costs nothing, and [AccountRepository.rescheduleAll]
|
||||
* cancels the schedule from outside any worker.
|
||||
*/
|
||||
private suspend fun stopForSignIn(accountId: Long, reason: String) {
|
||||
accountState.setNeedsSignIn(accountId, true)
|
||||
database.accounts().recordSync(accountId, at = null, error = reason)
|
||||
}
|
||||
|
||||
private fun fatal(accountId: Long, result: Result): Result {
|
||||
val reason = when (result) {
|
||||
is Result.NeedsSignIn -> result.reason
|
||||
is Result.Misconfigured -> result.reason
|
||||
is Result.Synced, Result.Paused -> return result
|
||||
}
|
||||
database.accounts().recordSync(accountId, at = null, error = reason)
|
||||
return result
|
||||
}
|
||||
|
||||
/** Split out from [sync] so the reconciliation can be driven without a network. */
|
||||
internal suspend fun syncCollections(
|
||||
account: AccountEntity,
|
||||
remoteFor: (HttpUrl) -> RemoteCalendar,
|
||||
): List<SyncReport> {
|
||||
// Lists owing a DELETE first: a task moved between two of this account's
|
||||
// collections then leaves the old one before it arrives in the new one,
|
||||
// which a server that keeps UIDs unique per account needs.
|
||||
val owing = database.tasks().listsWithTombstones().toSet()
|
||||
val lists = database.taskLists().syncedForAccount(account.id)
|
||||
.sortedBy { it.id !in owing }
|
||||
val listIds = lists.map { it.id }.toSet()
|
||||
|
||||
// ⚠️ Only this account's keys are written back. The counts are global
|
||||
// while the worker's uniqueness is only per account, so replacing the
|
||||
// whole map would discard a concurrently syncing account's increments and
|
||||
// resurrect the counters it had cleared.
|
||||
val before = quarantine.counts()
|
||||
val counts = before.toMutableMap()
|
||||
val syncer = CollectionSyncer(store)
|
||||
|
||||
val now = kotlin.time.Clock.System.now()
|
||||
val lastFull = cadence.lastFullSync()
|
||||
|
||||
// Never reconciled, or the token has been trusted long enough.
|
||||
val due = lists.associate { list ->
|
||||
val since = lastFull[list.id]
|
||||
list.id to (since == null || now - since >= SyncCadenceStore.FULL_RECONCILIATION_INTERVAL)
|
||||
}
|
||||
|
||||
val reports = lists.map { list ->
|
||||
val url = list.href?.toHttpUrlOrNull()
|
||||
?: return@map SyncReport(list.id, list.name, failure = "list has no collection URL")
|
||||
syncer.sync(
|
||||
list = list,
|
||||
remote = remoteFor(url),
|
||||
quarantine = counts,
|
||||
fullReconciliationDue = due[list.id] == true,
|
||||
)
|
||||
}
|
||||
|
||||
// ⚠️ Only the runs that were *due*. A server without `sync-collection`
|
||||
// reconciles in full every single time, so recording each one kept the
|
||||
// clock permanently fresh and `fullReconciliationDue` permanently false
|
||||
// — which costs nothing on that path, since the cursor is null anyway,
|
||||
// but silently disables everything else hung off the periodic mark. The
|
||||
// download-side quarantine probe is the one that matters: for exactly
|
||||
// those servers it would never have fired.
|
||||
cadence.record(
|
||||
reports.filter { it.reconciledInFull && it.failure == null && due[it.listId] == true }
|
||||
.associate { it.listId to now },
|
||||
)
|
||||
|
||||
fun mine(key: String) = key.substringBefore('|').toLongOrNull() in listIds
|
||||
quarantine.merge(
|
||||
updates = counts.filterKeys(::mine),
|
||||
cleared = before.keys.filter(::mine).filterNot { it in counts }.toSet(),
|
||||
)
|
||||
database.accounts().recordSync(
|
||||
accountId = account.id,
|
||||
at = now,
|
||||
error = reports.mapNotNull { it.failure }.firstOrNull(),
|
||||
)
|
||||
return reports
|
||||
}
|
||||
|
||||
private companion object {
|
||||
/**
|
||||
* Matches what the account-add flow signed in with, so Nextcloud's
|
||||
* Settings → Security → Devices & sessions keeps naming the app password
|
||||
* after the app rather than after OkHttp.
|
||||
*/
|
||||
const val USER_AGENT = "Agendula (Android)"
|
||||
}
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
package de.jeanlucmakiola.agendula.data.sync
|
||||
|
||||
/**
|
||||
* Why a sync failed, in classes a user can act on.
|
||||
*
|
||||
* `accounts.last_sync_error` holds the engine's own words — a collection
|
||||
* failure wrapping an exception's `toString()` — which are for logs and never
|
||||
* for the screen. This reads the class back out of them.
|
||||
*/
|
||||
data class SyncFailure(val kind: Kind, val httpCode: Int? = null) {
|
||||
|
||||
enum class Kind {
|
||||
/** The server refused the credentials. */
|
||||
SIGN_IN,
|
||||
|
||||
/** DNS, a refused connection, a timeout: nothing answered. */
|
||||
UNREACHABLE,
|
||||
|
||||
/** The TLS handshake failed — an untrusted or mismatched certificate. */
|
||||
CERTIFICATE,
|
||||
|
||||
/** The server answered, with an error of its own. */
|
||||
SERVER,
|
||||
|
||||
/** The account or a list is missing something the sync needs. */
|
||||
MISCONFIGURED,
|
||||
|
||||
/** Anything else: the collection did not finish, for a reason we do not name. */
|
||||
OTHER,
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
fun of(error: String): SyncFailure {
|
||||
val code = HTTP_CODE.find(error)?.groupValues?.get(1)?.toIntOrNull()
|
||||
return when {
|
||||
TLS.any { it in error } -> SyncFailure(Kind.CERTIFICATE)
|
||||
AUTH.any { it in error } || code == 401 -> SyncFailure(Kind.SIGN_IN)
|
||||
NETWORK.any { it in error } -> SyncFailure(Kind.UNREACHABLE)
|
||||
CONFIG.any { it in error } -> SyncFailure(Kind.MISCONFIGURED)
|
||||
"ServiceUnavailableException" in error -> SyncFailure(Kind.SERVER, code ?: 503)
|
||||
code != null -> SyncFailure(Kind.SERVER, code)
|
||||
else -> SyncFailure(Kind.OTHER)
|
||||
}
|
||||
}
|
||||
|
||||
private val HTTP_CODE = Regex("""\bHTTP (\d{3})\b""")
|
||||
|
||||
private val TLS = listOf(
|
||||
"SSLHandshakeException",
|
||||
"SSLPeerUnverifiedException",
|
||||
"CertPathValidatorException",
|
||||
"CertificateException",
|
||||
"SSLException",
|
||||
)
|
||||
|
||||
private val AUTH = listOf(
|
||||
"UnauthorizedException",
|
||||
"rejected the credentials",
|
||||
"no stored password",
|
||||
)
|
||||
|
||||
private val NETWORK = listOf(
|
||||
"UnknownHostException",
|
||||
"ConnectException",
|
||||
"NoRouteToHostException",
|
||||
"SocketTimeoutException",
|
||||
"InterruptedIOException",
|
||||
"SocketException",
|
||||
"EOFException",
|
||||
"timeout",
|
||||
)
|
||||
|
||||
private val CONFIG = listOf(
|
||||
"no such account",
|
||||
"has no username",
|
||||
"has no principal URL",
|
||||
"has no collection URL",
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,191 +0,0 @@
|
||||
package de.jeanlucmakiola.agendula.data.sync
|
||||
|
||||
import android.Manifest
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import de.jeanlucmakiola.agendula.MainActivity
|
||||
import de.jeanlucmakiola.agendula.R
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Tells the user what a background sync destroyed or gave up on.
|
||||
*
|
||||
* ⚠️ A notification, and not only a row on the accounts screen. Sync runs on a
|
||||
* four-hour timer while the app is closed, so a surface the user has to go and
|
||||
* look at means the discarded edit is discovered — if ever — days later, next to
|
||||
* a task that quietly says something else than what they typed. The account
|
||||
* screen keeps the detail; this is what makes them go there.
|
||||
*
|
||||
* Its own channel, at `IMPORTANCE_LOW`: it is a report rather than an alarm, and
|
||||
* it must be silenceable without taking due-task reminders with it.
|
||||
*/
|
||||
@Singleton
|
||||
class SyncNoticeNotifier @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
) {
|
||||
|
||||
fun canPost(): Boolean {
|
||||
val granted = Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
return granted && NotificationManagerCompat.from(context).areNotificationsEnabled()
|
||||
}
|
||||
|
||||
// canPost() checks POST_NOTIFICATIONS before we ever call notify().
|
||||
@SuppressLint("MissingPermission")
|
||||
fun post(accountName: String, notices: List<SyncNotice>) {
|
||||
if (notices.isEmpty() || !canPost()) return
|
||||
ensureChannel()
|
||||
|
||||
val discarded = notices.count { it.kind == SyncNotice.Kind.DISCARDED_EDIT }
|
||||
val quarantined = notices.size - discarded
|
||||
// ⚠️ A discarded edit outranks a quarantine even when there are more
|
||||
// quarantines, and the collapsed line says so. They are not equivalent:
|
||||
// an edit that lost is work already destroyed and unrecoverable, while a
|
||||
// quarantined task is a condition that persists and clears itself. The
|
||||
// big text below lists both, in full, whichever headline was chosen.
|
||||
val title = if (discarded > 0) {
|
||||
context.resources.getQuantityString(
|
||||
R.plurals.sync_notice_discarded_title, discarded, discarded,
|
||||
)
|
||||
} else {
|
||||
context.resources.getQuantityString(
|
||||
R.plurals.sync_notice_quarantined_title, quarantined, quarantined,
|
||||
)
|
||||
}
|
||||
|
||||
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
|
||||
.setSmallIcon(R.drawable.ic_notification)
|
||||
.setContentTitle(title)
|
||||
.setContentText(context.getString(R.string.sync_notice_body, accountName))
|
||||
.setStyle(NotificationCompat.BigTextStyle().bigText(summaryOf(notices)))
|
||||
.setCategory(NotificationCompat.CATEGORY_STATUS)
|
||||
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||
.setAutoCancel(true)
|
||||
.setContentIntent(
|
||||
PendingIntent.getActivity(
|
||||
context,
|
||||
accountName.hashCode(),
|
||||
MainActivity.openIntent(context),
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
),
|
||||
)
|
||||
.build()
|
||||
|
||||
// Tagged by account, so a second account's news replaces nothing.
|
||||
NotificationManagerCompat.from(context).notify(accountName, NOTIFICATION_ID, notification)
|
||||
}
|
||||
|
||||
/** "Sign in to <account> again", for a background sync the server refused. */
|
||||
// canPost() checks POST_NOTIFICATIONS before we ever call notify().
|
||||
@SuppressLint("MissingPermission")
|
||||
fun postSignIn(accountName: String, accountId: Long) {
|
||||
if (!canPost()) return
|
||||
ensureSignInChannel()
|
||||
val body = context.getString(R.string.sync_sign_in_body)
|
||||
val notification = NotificationCompat.Builder(context, SIGN_IN_CHANNEL_ID)
|
||||
.setSmallIcon(R.drawable.ic_notification)
|
||||
.setContentTitle(context.getString(R.string.sync_sign_in_title, accountName))
|
||||
.setContentText(body)
|
||||
.setStyle(NotificationCompat.BigTextStyle().bigText(body))
|
||||
.setCategory(NotificationCompat.CATEGORY_ERROR)
|
||||
.setAutoCancel(true)
|
||||
.setContentIntent(
|
||||
PendingIntent.getActivity(
|
||||
context,
|
||||
accountId.toInt(),
|
||||
signInIntent(accountId),
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
),
|
||||
)
|
||||
.build()
|
||||
NotificationManagerCompat.from(context).notify(accountName, SIGN_IN_NOTIFICATION_ID, notification)
|
||||
}
|
||||
|
||||
/** The account syncs again, so the prompt has done its job. */
|
||||
fun cancelSignIn(accountName: String) {
|
||||
NotificationManagerCompat.from(context).cancel(accountName, SIGN_IN_NOTIFICATION_ID)
|
||||
}
|
||||
|
||||
/**
|
||||
* Where tapping the sign-in prompt lands. Only opens the app for now; the
|
||||
* extra names the account for routing to Settings → Accounts → it.
|
||||
*/
|
||||
private fun signInIntent(accountId: Long): Intent =
|
||||
MainActivity.openIntent(context).putExtra(EXTRA_SIGN_IN_ACCOUNT_ID, accountId)
|
||||
|
||||
private fun ensureSignInChannel() {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
||||
val manager = context.getSystemService(NotificationManager::class.java)
|
||||
if (manager.getNotificationChannel(SIGN_IN_CHANNEL_ID) != null) return
|
||||
manager.createNotificationChannel(
|
||||
NotificationChannel(
|
||||
SIGN_IN_CHANNEL_ID,
|
||||
context.getString(R.string.sync_sign_in_channel_name),
|
||||
NotificationManager.IMPORTANCE_DEFAULT,
|
||||
).apply { description = context.getString(R.string.sync_sign_in_channel_desc) },
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The first few, by name.
|
||||
*
|
||||
* ⚠️ A count on its own is unactionable — "3 edits were replaced" leaves the
|
||||
* user to guess which three, across every list they own. The names are the
|
||||
* only part that makes the account screen worth opening.
|
||||
*/
|
||||
private fun summaryOf(notices: List<SyncNotice>): String {
|
||||
val named = notices.take(SUMMARY_LIMIT).joinToString("\n") { notice ->
|
||||
val subject = notice.subject.ifBlank { context.getString(R.string.task_untitled) }
|
||||
context.getString(R.string.sync_notice_line, subject, notice.listName)
|
||||
}
|
||||
val rest = notices.size - SUMMARY_LIMIT
|
||||
return if (rest > 0) {
|
||||
named + "\n" + context.resources.getQuantityString(R.plurals.sync_notice_more, rest, rest)
|
||||
} else {
|
||||
named
|
||||
}
|
||||
}
|
||||
|
||||
private fun ensureChannel() {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
||||
context.getSystemService(NotificationManager::class.java).createNotificationChannel(
|
||||
NotificationChannel(
|
||||
CHANNEL_ID,
|
||||
context.getString(R.string.sync_notice_channel_name),
|
||||
NotificationManager.IMPORTANCE_LOW,
|
||||
).apply { description = context.getString(R.string.sync_notice_channel_desc) },
|
||||
)
|
||||
}
|
||||
|
||||
/** Re-create the channel, if it exists, in the current language. */
|
||||
fun refreshChannel() {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
||||
val manager = context.getSystemService(NotificationManager::class.java)
|
||||
if (manager.getNotificationChannel(CHANNEL_ID) != null) ensureChannel()
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** The account a sign-in notification is about, on the intent it opens. */
|
||||
const val EXTRA_SIGN_IN_ACCOUNT_ID = "de.jeanlucmakiola.agendula.extra.SIGN_IN_ACCOUNT_ID"
|
||||
|
||||
private const val CHANNEL_ID = "sync_notices"
|
||||
private const val NOTIFICATION_ID = 2
|
||||
private const val SIGN_IN_CHANNEL_ID = "account_sign_in"
|
||||
private const val SIGN_IN_NOTIFICATION_ID = 3
|
||||
|
||||
/** Enough to recognise the work; the screen has the rest. */
|
||||
private const val SUMMARY_LIMIT = 5
|
||||
}
|
||||
}
|
||||
@@ -1,279 +0,0 @@
|
||||
package de.jeanlucmakiola.agendula.data.sync
|
||||
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.stringSetPreferencesKey
|
||||
import de.jeanlucmakiola.agendula.data.di.SyncStateDataStore
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.map
|
||||
import java.util.Base64
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
* One thing a sync did that the user would not otherwise find out about.
|
||||
*
|
||||
* ⚠️ Conflict policy is **server wins, local edit discarded**, and
|
||||
* [SyncReport]'s own doc says the report is "the other half of the decision, not
|
||||
* a nice-to-have". Until this existed the other half was a `Log.i` — the edit was
|
||||
* gone, nothing in `ui/` read `discardedEdits`, and from where the user sits that
|
||||
* is indistinguishable from the app losing their work.
|
||||
*/
|
||||
data class SyncNotice(
|
||||
val accountId: Long,
|
||||
val listName: String,
|
||||
val kind: Kind,
|
||||
/** The task's title for a discarded edit, the resource's name for a quarantine. */
|
||||
val subject: String,
|
||||
/**
|
||||
* What makes this notice distinct from another about a different task.
|
||||
*
|
||||
* ⚠️ Carried but never shown. These are stored as a `Set<String>`, and two
|
||||
* discarded edits from one run share an account, a list, a cause and a
|
||||
* timestamp — so two *untitled* tasks, or two both called "Milk", encoded
|
||||
* identically and one of them silently vanished. The notification counted
|
||||
* two and the screen listed one. The UID is the only thing that tells them
|
||||
* apart, and it is exactly what must not reach the user: opaque text chosen
|
||||
* by whoever created the task.
|
||||
*/
|
||||
val key: String,
|
||||
/** Why the edit lost. Null for a quarantine, which has no such choice behind it. */
|
||||
val cause: DiscardedEdit.Cause?,
|
||||
val at: Instant,
|
||||
) {
|
||||
enum class Kind { DISCARDED_EDIT, QUARANTINED }
|
||||
}
|
||||
|
||||
/**
|
||||
* What the last syncs destroyed or gave up on, per account, until it is read.
|
||||
*
|
||||
* The two kinds keep different company, which is why they are written
|
||||
* differently:
|
||||
*
|
||||
* - A **discarded edit** is news. It happened once, it cannot be undone, and a
|
||||
* later clean sync does not make it untrue — so it accumulates and is cleared
|
||||
* only by the user acknowledging it. Replacing the set every run would let a
|
||||
* quiet sync an hour later erase the one thing worth saying.
|
||||
* - A **quarantined resource** is a standing condition: one task has stopped
|
||||
* syncing while the rest of its list is fine. It is re-reported on every run
|
||||
* for as long as it holds, so the account's set of them is *replaced* each
|
||||
* time — which is also how it clears itself the moment the resource starts
|
||||
* working again.
|
||||
*
|
||||
* Lives with the other per-device sync state, and is therefore excluded from
|
||||
* backup — see [SyncStateDataStore]. Correct on its own terms too: a restored
|
||||
* device has not discarded anything.
|
||||
*/
|
||||
@Singleton
|
||||
class SyncNoticeStore @Inject constructor(
|
||||
@SyncStateDataStore private val dataStore: DataStore<Preferences>,
|
||||
) {
|
||||
|
||||
/** Observed, so a background sync's news reaches a screen that is already open. */
|
||||
fun observeAll(): Flow<List<SyncNotice>> = dataStore.data.map { prefs ->
|
||||
prefs[KEY].orEmpty().mapNotNull(::decode).sortedByDescending { it.at }
|
||||
}
|
||||
|
||||
/**
|
||||
* Folds one account's run into the store.
|
||||
*
|
||||
* @return only what is **new**, which is what a notification may be posted
|
||||
* for. A quarantine already on record is a condition the user has already
|
||||
* been told about, and re-announcing it on every four-hour run would train
|
||||
* them to ignore the one that matters.
|
||||
*/
|
||||
/**
|
||||
* @param titles the local title of each quarantined resource, by href.
|
||||
* ⚠️ Not optional decoration. Without it the row read
|
||||
* `a1f9c3e2-….ics`, which is the opaque blob this file refuses to show
|
||||
* for a discarded edit — and "a task has stopped syncing" that does not
|
||||
* say which task is the very failure the feature exists to fix. Absent
|
||||
* only for a resource we never stored, where the filename is genuinely
|
||||
* all there is.
|
||||
*/
|
||||
suspend fun record(
|
||||
accountId: Long,
|
||||
at: Instant,
|
||||
reports: List<SyncReport>,
|
||||
titles: Map<String, String> = emptyMap(),
|
||||
): List<SyncNotice> {
|
||||
val discarded = reports.flatMap { report ->
|
||||
report.discardedEdits.map { edit ->
|
||||
SyncNotice(
|
||||
accountId = accountId,
|
||||
listName = report.listName,
|
||||
kind = SyncNotice.Kind.DISCARDED_EDIT,
|
||||
// The UID is not shown to anyone: it is opaque text chosen by
|
||||
// whoever created the task, routinely a bare hex blob.
|
||||
subject = edit.title.orEmpty(),
|
||||
key = edit.uid,
|
||||
cause = edit.cause,
|
||||
at = at,
|
||||
)
|
||||
}
|
||||
}
|
||||
// ⚠️ Only the ones that have actually stopped. Below the threshold the
|
||||
// resource is still being retried, and "one of your tasks has stopped
|
||||
// syncing" would be untrue of a single 502 from a proxy mid-restart.
|
||||
val quarantined = reports.flatMap { report ->
|
||||
report.quarantined
|
||||
.filter { it.failures >= QuarantineStore.THRESHOLD }
|
||||
.map { resource ->
|
||||
SyncNotice(
|
||||
accountId = accountId,
|
||||
listName = report.listName,
|
||||
kind = SyncNotice.Kind.QUARANTINED,
|
||||
subject = titles[resource.href] ?: resource.href.substringAfterLast('/'),
|
||||
key = resource.href,
|
||||
cause = null,
|
||||
at = at,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ⚠️ Nothing to say is the overwhelmingly common case — most syncs
|
||||
// discard nothing and quarantine nothing — and a DataStore edit rewrites
|
||||
// and fsyncs the whole file. Skipped only when there is also nothing on
|
||||
// record to clear, or a recovered resource would keep its notice for ever.
|
||||
if (discarded.isEmpty() && quarantined.isEmpty() && !hasRecord(accountId)) {
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
var added = emptyList<SyncNotice>()
|
||||
dataStore.edit { prefs ->
|
||||
// ⚠️ Re-read inside `edit`, which DataStore serialises. The set is
|
||||
// global while `SyncWorker`'s uniqueness is only per account, so two
|
||||
// accounts can be folding in at once and a snapshot taken outside
|
||||
// would discard the other's.
|
||||
val current = prefs[KEY].orEmpty().mapNotNull(::decode)
|
||||
val others = current.filter { it.accountId != accountId }
|
||||
val keptDiscards = current.filter {
|
||||
it.accountId == accountId && it.kind == SyncNotice.Kind.DISCARDED_EDIT
|
||||
}
|
||||
val standing = current.filter {
|
||||
it.accountId == accountId && it.kind == SyncNotice.Kind.QUARANTINED
|
||||
}
|
||||
added = discarded + quarantined.filterNot { fresh ->
|
||||
standing.any { it.key == fresh.key }
|
||||
}
|
||||
// Newest first, then capped: an account that has been failing for a
|
||||
// week must not grow this without bound, and the oldest news is the
|
||||
// least actionable.
|
||||
val kept = (discarded + keptDiscards).sortedByDescending { it.at }.take(MAX_PER_ACCOUNT)
|
||||
// ⚠️ Capped as well, and the class doc used to claim it did not need
|
||||
// to be. "Bounded by the collection" is only true of a healthy one:
|
||||
// a server answering 415 to four hundred resources puts four hundred
|
||||
// entries in one preference key, rewritten on every run — and the
|
||||
// account screen renders them into a plain scrolling column.
|
||||
val standingNow = quarantined.take(MAX_PER_ACCOUNT)
|
||||
val updated = (others + kept + standingNow).map(::encode).toSet()
|
||||
// ⚠️ Only when it differs. A DataStore edit rewrites and fsyncs the
|
||||
// whole file, and an account holding one un-dismissed notice would
|
||||
// otherwise pay that on every four-hour sync until the user tapped
|
||||
// "Got it" — which is the cost the fast path above claims to avoid.
|
||||
if (updated != prefs[KEY]) prefs[KEY] = updated
|
||||
}
|
||||
return added
|
||||
}
|
||||
|
||||
private suspend fun hasRecord(accountId: Long): Boolean =
|
||||
dataStore.data.first().let { prefs ->
|
||||
prefs[KEY].orEmpty().mapNotNull(::decode).any { it.accountId == accountId }
|
||||
}
|
||||
|
||||
/**
|
||||
* Forgets one list's notices, for a list that has just been deleted.
|
||||
*
|
||||
* By name, because that is how they are keyed — there is no list id in a
|
||||
* notice, and by the time this is called the row it would have named is
|
||||
* already gone.
|
||||
*/
|
||||
suspend fun forgetList(accountId: Long, listName: String) {
|
||||
dataStore.edit { prefs ->
|
||||
val kept = prefs[KEY].orEmpty()
|
||||
.mapNotNull(::decode)
|
||||
.filterNot { it.accountId == accountId && it.listName == listName }
|
||||
.map(::encode)
|
||||
.toSet()
|
||||
if (kept != prefs[KEY]) prefs[KEY] = kept
|
||||
}
|
||||
}
|
||||
|
||||
/** Drops one quarantine notice, for a resource the user has asked to retry. */
|
||||
suspend fun forgetQuarantined(accountId: Long, key: String) {
|
||||
dataStore.edit { prefs ->
|
||||
val kept = prefs[KEY].orEmpty()
|
||||
.mapNotNull(::decode)
|
||||
.filterNot {
|
||||
it.accountId == accountId && it.kind == SyncNotice.Kind.QUARANTINED && it.key == key
|
||||
}
|
||||
.map(::encode)
|
||||
.toSet()
|
||||
if (kept != prefs[KEY]) prefs[KEY] = kept
|
||||
}
|
||||
}
|
||||
|
||||
/** The user has read them. */
|
||||
suspend fun dismiss(accountId: Long) {
|
||||
dataStore.edit { prefs ->
|
||||
prefs[KEY] = prefs[KEY].orEmpty()
|
||||
.mapNotNull(::decode)
|
||||
.filterNot { it.accountId == accountId }
|
||||
.map(::encode)
|
||||
.toSet()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ⚠️ Base64 around the free text, not a delimiter and a hope. A list is
|
||||
* named by its owner and a task is titled by its author, so both can hold
|
||||
* any character at all — including whatever separator looked safe.
|
||||
*/
|
||||
private fun encode(notice: SyncNotice): String = listOf(
|
||||
notice.accountId.toString(),
|
||||
notice.kind.name,
|
||||
notice.cause?.name.orEmpty(),
|
||||
notice.at.toEpochMilliseconds().toString(),
|
||||
base64(notice.listName),
|
||||
base64(notice.subject),
|
||||
base64(notice.key),
|
||||
).joinToString(SEPARATOR)
|
||||
|
||||
private fun decode(entry: String): SyncNotice? {
|
||||
val parts = entry.split(SEPARATOR)
|
||||
if (parts.size != FIELDS) return null
|
||||
val accountId = parts[0].toLongOrNull() ?: return null
|
||||
val kind = SyncNotice.Kind.entries.firstOrNull { it.name == parts[1] } ?: return null
|
||||
val at = parts[3].toLongOrNull() ?: return null
|
||||
return SyncNotice(
|
||||
accountId = accountId,
|
||||
listName = unBase64(parts[4]) ?: return null,
|
||||
kind = kind,
|
||||
subject = unBase64(parts[5]) ?: return null,
|
||||
key = unBase64(parts[6]) ?: return null,
|
||||
cause = DiscardedEdit.Cause.entries.firstOrNull { it.name == parts[2] },
|
||||
at = Instant.fromEpochMilliseconds(at),
|
||||
)
|
||||
}
|
||||
|
||||
private fun base64(value: String): String =
|
||||
Base64.getUrlEncoder().encodeToString(value.toByteArray(Charsets.UTF_8))
|
||||
|
||||
private fun unBase64(value: String): String? = runCatching {
|
||||
String(Base64.getUrlDecoder().decode(value), Charsets.UTF_8)
|
||||
}.getOrNull()
|
||||
|
||||
private companion object {
|
||||
val KEY = stringSetPreferencesKey("sync_notices")
|
||||
|
||||
/** Not present in URL-safe Base64, nor in a decimal or an enum name. */
|
||||
const val SEPARATOR = "|"
|
||||
const val FIELDS = 7
|
||||
|
||||
/** Discarded edits, per account. Quarantines are bounded by the collection. */
|
||||
const val MAX_PER_ACCOUNT = 50
|
||||
}
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
package de.jeanlucmakiola.agendula.data.sync
|
||||
|
||||
import android.util.Log
|
||||
import de.jeanlucmakiola.agendula.data.tasks.room.LocalWriteListener
|
||||
import de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Pushes a local edit to its account soon after it is made.
|
||||
*
|
||||
* Hooked into the store's own write paths rather than Room's invalidation
|
||||
* tracker: sync writes the same tables, and an observer cannot tell its own
|
||||
* downloads from the user's edits.
|
||||
*/
|
||||
@Singleton
|
||||
class SyncOnEdit @Inject constructor(
|
||||
private val database: TasksDatabase,
|
||||
private val trigger: SyncTrigger,
|
||||
) : LocalWriteListener {
|
||||
|
||||
override fun onWritten(listIds: Set<Long>) {
|
||||
try {
|
||||
listIds.mapNotNull { database.taskLists().entity(it)?.accountId }
|
||||
.toSet()
|
||||
.mapNotNull { database.accounts().account(it)?.displayName }
|
||||
.forEach(trigger::pushSoon)
|
||||
} catch (e: Exception) {
|
||||
// The edit is saved either way; the schedule picks it up later.
|
||||
Log.w(TAG, "could not schedule a push", e)
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val TAG = "SyncOnEdit"
|
||||
}
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
package de.jeanlucmakiola.agendula.data.sync
|
||||
|
||||
import android.content.Context
|
||||
import androidx.hilt.work.HiltWorker
|
||||
import androidx.work.CoroutineWorker
|
||||
import androidx.work.WorkerParameters
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedInject
|
||||
|
||||
/**
|
||||
* The end of [SyncTrigger.pushSoon]'s debounce: hands the account to a real
|
||||
* sync. Instant, so the REPLACE that restarts the debounce only ever cancels a
|
||||
* timer.
|
||||
*/
|
||||
@HiltWorker
|
||||
class SyncPushWorker @AssistedInject constructor(
|
||||
@Assisted context: Context,
|
||||
@Assisted parameters: WorkerParameters,
|
||||
private val trigger: SyncTrigger,
|
||||
) : CoroutineWorker(context, parameters) {
|
||||
|
||||
override suspend fun doWork(): Result {
|
||||
val accountName = inputData.getString(SyncWorker.KEY_ACCOUNT_NAME) ?: return Result.failure()
|
||||
trigger.enqueueAfterRunning(accountName)
|
||||
return Result.success()
|
||||
}
|
||||
}
|
||||
@@ -1,105 +0,0 @@
|
||||
package de.jeanlucmakiola.agendula.data.sync
|
||||
|
||||
import de.jeanlucmakiola.caldav.PushSupport
|
||||
|
||||
/**
|
||||
* What a sync did, and — the part that matters — what it destroyed.
|
||||
*
|
||||
* ⚠️ Conflict policy is **server wins, local edit discarded**.
|
||||
* That policy terminates, which is why it was chosen over forking under a new
|
||||
* UID, but on its own it is indistinguishable from data loss: the user's edit is
|
||||
* gone and nothing said so. The report is the other half of the decision, not a
|
||||
* nice-to-have — [discardedEdits] is why this type exists.
|
||||
*/
|
||||
data class SyncReport(
|
||||
val listId: Long,
|
||||
val listName: String,
|
||||
val downloaded: Int = 0,
|
||||
val uploaded: Int = 0,
|
||||
val deletedRemotely: Int = 0,
|
||||
val deletedLocally: Int = 0,
|
||||
/** Local edits thrown away because the server's copy was newer. */
|
||||
val discardedEdits: List<DiscardedEdit> = emptyList(),
|
||||
/** Resources the collection gave up on, so the rest of it could finish. */
|
||||
val quarantined: List<QuarantinedResource> = emptyList(),
|
||||
/**
|
||||
* Writes sent without `If-Match` because the server offers no usable
|
||||
* validator. Not an error, but the one case where a concurrent edit can be
|
||||
* overwritten without us noticing, so it is said out loud.
|
||||
*/
|
||||
val unconditionalWrites: Int = 0,
|
||||
/**
|
||||
* Whether this run reconciled against a full listing rather than a change log.
|
||||
*
|
||||
* ⚠️ Tracked because a token the server accepts over a change log it has
|
||||
* already pruned returns 207, zero changes and no error — RFC 6578 gives no
|
||||
* signal for it at all. The only mitigation is to reconcile in full on a slow
|
||||
* cadence regardless of the token, which means knowing when we last did.
|
||||
*/
|
||||
val reconciledInFull: Boolean = false,
|
||||
/**
|
||||
* Why the change-log path was abandoned, on a run the full path then
|
||||
* completed.
|
||||
*
|
||||
* Not a [failure]: the collection is reconciled and the user has nothing to
|
||||
* act on. Kept because a server that rejects `sync-collection` every time
|
||||
* will do it again, and that is worth seeing in a log without it becoming an
|
||||
* error in the UI.
|
||||
*/
|
||||
val incrementalNote: String? = null,
|
||||
/**
|
||||
* The server refused our credentials.
|
||||
*
|
||||
* ⚠️ Escalates to the whole account and stops it, unlike every other failure
|
||||
* here. Nextcloud's brute-force protection throttles and then **429s per
|
||||
* source IP**, so a client that keeps retrying a dead app password on a timer
|
||||
* takes the user's *other* Nextcloud clients down with it, on that network,
|
||||
* and looks from the outside like we broke their server. There is nothing to
|
||||
* retry anyway: only the user can fix it.
|
||||
*/
|
||||
val authFailure: Boolean = false,
|
||||
/** Set when the collection failed as a whole. The account keeps going. */
|
||||
val failure: String? = null,
|
||||
/**
|
||||
* The collection's own properties were read this run, so [pushSupport] is
|
||||
* the server's answer rather than the absence of one.
|
||||
*/
|
||||
val collectionRead: Boolean = false,
|
||||
/** WebDAV-Push, as the collection offered it this run. */
|
||||
val pushSupport: PushSupport? = null,
|
||||
) {
|
||||
val hadWork: Boolean
|
||||
get() = downloaded > 0 || uploaded > 0 || deletedRemotely > 0 || deletedLocally > 0
|
||||
}
|
||||
|
||||
/** One local edit that lost to the server. */
|
||||
data class DiscardedEdit(
|
||||
val uid: String,
|
||||
val title: String?,
|
||||
val cause: Cause,
|
||||
) {
|
||||
enum class Cause {
|
||||
/** The server's copy changed after we last read it. */
|
||||
SERVER_NEWER,
|
||||
|
||||
/** The task was deleted on the server while it was edited here. */
|
||||
DELETED_ON_SERVER,
|
||||
|
||||
/** Deleted here, but changed on the server after that. The delete lost. */
|
||||
DELETE_LOST,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A resource the collection stopped trying.
|
||||
*
|
||||
* ⚠️ Quarantine is a **counter, not a backoff**. A single HTTP 400 on one
|
||||
* resource has halted all of a user's calendar sync in DAVx5 for weeks; the
|
||||
* failure has to be contained to the resource that caused it, and the rest of
|
||||
* the collection has to complete.
|
||||
*/
|
||||
data class QuarantinedResource(
|
||||
val href: String,
|
||||
val reason: String,
|
||||
val failures: Int,
|
||||
)
|
||||
@@ -0,0 +1,6 @@
|
||||
package de.jeanlucmakiola.agendula.data.sync
|
||||
|
||||
/** Asks for an account to be synced now. A no-op in the `offline` flavor. */
|
||||
fun interface SyncRequests {
|
||||
fun syncNow(accountName: String)
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
package de.jeanlucmakiola.agendula.data.sync
|
||||
|
||||
import de.jeanlucmakiola.agendula.data.tasks.room.TaskEntity
|
||||
import de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* The database, as [CollectionSyncer] needs it.
|
||||
*
|
||||
* A seam, and the reason is the same one that put [CalDavGateway] in front of
|
||||
* discovery: the reconciliation above this interface is where local edits are
|
||||
* discarded, tombstones swept and conflicts resolved, and every one of those is
|
||||
* a decision that should be provable without a device. Room's test double is
|
||||
* Robolectric plus an in-memory database; this is eight methods.
|
||||
*/
|
||||
interface SyncStore {
|
||||
|
||||
/** Every row in a list, **tombstones included**. */
|
||||
fun rowsIn(listId: Long): List<TaskEntity>
|
||||
|
||||
fun insert(row: TaskEntity): Long
|
||||
|
||||
fun update(row: TaskEntity)
|
||||
|
||||
fun deleteAll(taskIds: List<Long>)
|
||||
|
||||
/**
|
||||
* Records href and ETag on a resource's rows, clearing `is_dirty`.
|
||||
*
|
||||
* The caller excludes any row it left out of the body — that row is deleted,
|
||||
* not marked synced.
|
||||
*/
|
||||
fun markSynced(taskIds: List<Long>, href: String?, eTag: String?)
|
||||
|
||||
fun setParent(taskId: Long, parentId: Long?)
|
||||
|
||||
/** The master row for a UID — the one with no `RECURRENCE-ID`. */
|
||||
fun masterByUid(listId: Long, uid: String): TaskEntity?
|
||||
|
||||
fun row(taskId: Long): TaskEntity?
|
||||
|
||||
/**
|
||||
* One column, deliberately. A whole-entity update would carry the row as it
|
||||
* looked when the sync started and revert anything the user changed while it
|
||||
* ran.
|
||||
*/
|
||||
fun setListReadOnly(listId: Long, readOnly: Boolean)
|
||||
|
||||
/** The RFC 6578 cursor. Null resets the collection to a full reconciliation. */
|
||||
fun setSyncToken(listId: Long, token: String?)
|
||||
}
|
||||
|
||||
class RoomSyncStore @Inject constructor(
|
||||
private val database: TasksDatabase,
|
||||
) : SyncStore {
|
||||
|
||||
override fun rowsIn(listId: Long) = database.tasks().allIn(listId)
|
||||
|
||||
override fun insert(row: TaskEntity) = database.tasks().insert(row)
|
||||
|
||||
override fun update(row: TaskEntity) {
|
||||
database.tasks().update(row)
|
||||
}
|
||||
|
||||
override fun deleteAll(taskIds: List<Long>) {
|
||||
if (taskIds.isNotEmpty()) database.tasks().deleteAll(taskIds)
|
||||
}
|
||||
|
||||
override fun markSynced(taskIds: List<Long>, href: String?, eTag: String?) {
|
||||
if (taskIds.isNotEmpty()) database.tasks().markSynced(taskIds, href, eTag)
|
||||
}
|
||||
|
||||
override fun setParent(taskId: Long, parentId: Long?) {
|
||||
database.tasks().setParent(taskId, parentId)
|
||||
}
|
||||
|
||||
override fun masterByUid(listId: Long, uid: String) = database.tasks().byUid(listId, uid)
|
||||
|
||||
override fun row(taskId: Long) = database.tasks().entity(taskId)
|
||||
|
||||
override fun setListReadOnly(listId: Long, readOnly: Boolean) {
|
||||
database.taskLists().setReadOnly(listId, readOnly)
|
||||
}
|
||||
|
||||
override fun setSyncToken(listId: Long, token: String?) {
|
||||
database.taskLists().setSyncToken(listId, token)
|
||||
}
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
package de.jeanlucmakiola.agendula.data.sync
|
||||
|
||||
import android.content.ContentProvider
|
||||
import android.content.ContentValues
|
||||
import android.database.Cursor
|
||||
import android.net.Uri
|
||||
|
||||
/**
|
||||
* A `ContentProvider` that stores nothing.
|
||||
*
|
||||
* It exists because **a sync adapter is registered against a content
|
||||
* authority**, and Agendula publishes no provider — `:provider` was deleted when
|
||||
* we took our own Room store. Without an authority there
|
||||
* is nothing for `<sync-adapter android:contentAuthority>` to name, nothing for
|
||||
* `ContentResolver.requestSync` to address, and nothing for system Settings to
|
||||
* render a sync switch against.
|
||||
*
|
||||
* The sync-adapter registration is not optional:
|
||||
* `ContentService.hasAuthorityAccess()` gates `requestSync`,
|
||||
* `setSyncAutomatically`, `addPeriodicSync`, `setIsSyncable` and seven more
|
||||
* behind a compat change that is **on for targetSdk ≥ 34**, and with nothing
|
||||
* registered every one of those calls returns silently — no exception, no log,
|
||||
* and it passes on a Robolectric shadow. This provider is the cheapest way to
|
||||
* hold up the other end of that requirement.
|
||||
*
|
||||
* Not exported, and every method is a no-op. Real data lives in Room.
|
||||
*/
|
||||
class SyncStubProvider : ContentProvider() {
|
||||
|
||||
override fun onCreate() = true
|
||||
|
||||
override fun query(
|
||||
uri: Uri,
|
||||
projection: Array<out String>?,
|
||||
selection: String?,
|
||||
selectionArgs: Array<out String>?,
|
||||
sortOrder: String?,
|
||||
): Cursor? = null
|
||||
|
||||
override fun getType(uri: Uri): String? = null
|
||||
|
||||
override fun insert(uri: Uri, values: ContentValues?): Uri? = null
|
||||
|
||||
override fun delete(uri: Uri, selection: String?, selectionArgs: Array<out String>?) = 0
|
||||
|
||||
override fun update(
|
||||
uri: Uri,
|
||||
values: ContentValues?,
|
||||
selection: String?,
|
||||
selectionArgs: Array<out String>?,
|
||||
) = 0
|
||||
}
|
||||
@@ -1,201 +0,0 @@
|
||||
package de.jeanlucmakiola.agendula.data.sync
|
||||
|
||||
import android.content.Context
|
||||
import androidx.work.Constraints
|
||||
import androidx.work.Data
|
||||
import androidx.work.ExistingPeriodicWorkPolicy
|
||||
import androidx.work.ExistingWorkPolicy
|
||||
import androidx.work.NetworkType
|
||||
import androidx.work.OneTimeWorkRequestBuilder
|
||||
import androidx.work.OutOfQuotaPolicy
|
||||
import androidx.work.PeriodicWorkRequestBuilder
|
||||
import androidx.work.WorkInfo
|
||||
import androidx.work.WorkManager
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import java.util.concurrent.TimeUnit
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.hours
|
||||
import kotlin.time.Duration.Companion.minutes
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
/**
|
||||
* Starts a sync for one account.
|
||||
*
|
||||
* Shared by the sync adapter and by the app's own "sync now", because the app
|
||||
* cannot rely on the system trigger: ⚠️ `ContentService.hasAuthorityAccess()`
|
||||
* gates `requestSync` behind a compat change that is on at targetSdk ≥ 34, and
|
||||
* our authority is `userVisible="false"`, so Settings greys "Sync now" out. The
|
||||
* in-app button enqueues the work directly and is unaffected.
|
||||
*/
|
||||
@Singleton
|
||||
class SyncTrigger @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
) {
|
||||
|
||||
/**
|
||||
* Starts a sync now.
|
||||
*
|
||||
* @param expedited for a trigger the user is looking at. ⚠️ Paired with
|
||||
* `RUN_AS_NON_EXPEDITED_WORK_REQUEST`, which is not optional: the expedited
|
||||
* quota is per-app and exhaustible, and the alternative policy
|
||||
* (`DROP_WORK_REQUEST`) silently discards the sync the user just asked for.
|
||||
* Never set from a background trigger — a boot receiver spending the quota
|
||||
* leaves none for the button.
|
||||
* @return the unique work name, which the caller may wait on.
|
||||
*/
|
||||
fun enqueue(accountName: String, expedited: Boolean = false): String {
|
||||
val uniqueName = SyncWorker.uniqueNameFor(accountName)
|
||||
val request = OneTimeWorkRequestBuilder<SyncWorker>()
|
||||
.setInputData(inputFor(accountName))
|
||||
.setConstraints(NETWORK)
|
||||
.apply {
|
||||
if (expedited) setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)
|
||||
}
|
||||
.build()
|
||||
|
||||
WorkManager.getInstance(context).enqueueUniqueWork(
|
||||
uniqueName,
|
||||
// KEEP, not REPLACE: a periodic trigger arriving while a manual sync
|
||||
// is mid-flight must not cancel it and lose the cursor.
|
||||
ExistingWorkPolicy.KEEP,
|
||||
request,
|
||||
)
|
||||
return uniqueName
|
||||
}
|
||||
|
||||
/**
|
||||
* Pushes a local edit soon, rather than on the next periodic window.
|
||||
*
|
||||
* Debounced: each call restarts [PUSH_DELAY], so a burst of edits costs one
|
||||
* sync. What waits out the delay is a [SyncPushWorker], which hands over to
|
||||
* [enqueueAfterRunning] — so the replace can only ever cancel a timer, never
|
||||
* a sync that is mid-flight.
|
||||
*/
|
||||
fun pushSoon(accountName: String) {
|
||||
val request = OneTimeWorkRequestBuilder<SyncPushWorker>()
|
||||
.setInputData(inputFor(accountName))
|
||||
.setInitialDelay(PUSH_DELAY.inWholeSeconds, TimeUnit.SECONDS)
|
||||
.build()
|
||||
WorkManager.getInstance(context).enqueueUniqueWork(
|
||||
pushNameFor(accountName),
|
||||
ExistingWorkPolicy.REPLACE,
|
||||
request,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A sync that runs after any one already running for [accountName].
|
||||
*
|
||||
* Not [enqueue]'s KEEP: a sync already past its upload phase would swallow
|
||||
* this request and leave the edit that prompted it for the next window.
|
||||
*/
|
||||
fun enqueueAfterRunning(accountName: String) {
|
||||
val request = OneTimeWorkRequestBuilder<SyncWorker>()
|
||||
.setInputData(inputFor(accountName))
|
||||
.setConstraints(NETWORK)
|
||||
.build()
|
||||
WorkManager.getInstance(context).enqueueUniqueWork(
|
||||
SyncWorker.uniqueNameFor(accountName),
|
||||
ExistingWorkPolicy.APPEND_OR_REPLACE,
|
||||
request,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A sync for a push message: the server says something changed.
|
||||
*
|
||||
* Appended like [enqueueAfterRunning], since a sync already past its
|
||||
* download would miss the change — but only once. A burst of pushes during
|
||||
* one sync must cost one more sync, not one each.
|
||||
*/
|
||||
suspend fun enqueueFromPush(accountName: String) = pushEnqueue.withLock {
|
||||
val waiting = WorkManager.getInstance(context)
|
||||
.getWorkInfosForUniqueWorkFlow(SyncWorker.uniqueNameFor(accountName))
|
||||
.first()
|
||||
.any { it.state == WorkInfo.State.ENQUEUED || it.state == WorkInfo.State.BLOCKED }
|
||||
if (!waiting) enqueueAfterRunning(accountName)
|
||||
}
|
||||
|
||||
/** Serialises [enqueueFromPush]'s check and its enqueue across concurrent messages. */
|
||||
private val pushEnqueue = Mutex()
|
||||
|
||||
/**
|
||||
* Puts the account on the periodic schedule.
|
||||
*
|
||||
* A plain `PeriodicWorkRequest` and no foreground service, deliberately —
|
||||
* see [SyncWorker]. WorkManager restores its own schedule after a reboot, so
|
||||
* nothing has to re-arm this from `BOOT_COMPLETED`; that matters because
|
||||
* Android 15 forbids starting a `dataSync` foreground service from boot, and
|
||||
* a design that needed one would have no way to run at all.
|
||||
*
|
||||
* ⚠️ Be honest about the cadence in the UI. The interval setting is a
|
||||
* floor, not a promise: in the `rare` and `restricted` App Standby buckets
|
||||
* network access is off entirely, and the genuine worst case is once overnight.
|
||||
*
|
||||
* @param intervalMinutes the sync-interval setting; 0 (manual only) takes
|
||||
* the account off the schedule.
|
||||
* @param intervalChanged the user just picked a new interval, so a schedule
|
||||
* already in place is replaced rather than kept.
|
||||
*/
|
||||
fun schedule(accountName: String, intervalMinutes: Int, intervalChanged: Boolean = false) {
|
||||
val minutes = intervalMinutes
|
||||
if (minutes <= 0) {
|
||||
WorkManager.getInstance(context).cancelUniqueWork(periodicNameFor(accountName))
|
||||
return
|
||||
}
|
||||
val request = PeriodicWorkRequestBuilder<SyncWorker>(
|
||||
minutes.toLong(), TimeUnit.MINUTES,
|
||||
flexFor(minutes).inWholeMinutes, TimeUnit.MINUTES,
|
||||
)
|
||||
.setInputData(inputFor(accountName))
|
||||
.setConstraints(NETWORK)
|
||||
.build()
|
||||
|
||||
WorkManager.getInstance(context).enqueueUniquePeriodicWork(
|
||||
periodicNameFor(accountName),
|
||||
// UPDATE on every call would restart the interval on every app
|
||||
// launch, so a device that is opened often would never reach the
|
||||
// end of one. Only a changed interval replaces it.
|
||||
if (intervalChanged) ExistingPeriodicWorkPolicy.UPDATE else ExistingPeriodicWorkPolicy.KEEP,
|
||||
request,
|
||||
)
|
||||
}
|
||||
|
||||
/** Takes a removed account off the schedule. */
|
||||
fun cancel(accountName: String) {
|
||||
WorkManager.getInstance(context).apply {
|
||||
cancelUniqueWork(periodicNameFor(accountName))
|
||||
cancelUniqueWork(pushNameFor(accountName))
|
||||
cancelUniqueWork(SyncWorker.uniqueNameFor(accountName))
|
||||
}
|
||||
}
|
||||
|
||||
private fun inputFor(accountName: String) =
|
||||
Data.Builder().putString(SyncWorker.KEY_ACCOUNT_NAME, accountName).build()
|
||||
|
||||
private companion object {
|
||||
/** How long a local edit waits for more before it is pushed. */
|
||||
val PUSH_DELAY: Duration = 30.seconds
|
||||
|
||||
/** The tail of each interval the system may run us in: a quarter of it, at most an hour. */
|
||||
fun flexFor(intervalMinutes: Int): Duration =
|
||||
(intervalMinutes / 4).minutes.coerceIn(5.minutes, 1.hours)
|
||||
|
||||
/**
|
||||
* Sync needs a network, and saying so lets WorkManager run us the moment
|
||||
* connectivity returns rather than on the next interval.
|
||||
*/
|
||||
val NETWORK: Constraints = Constraints.Builder()
|
||||
.setRequiredNetworkType(NetworkType.CONNECTED)
|
||||
.build()
|
||||
|
||||
fun periodicNameFor(accountName: String) = "caldav-sync-periodic:$accountName"
|
||||
|
||||
fun pushNameFor(accountName: String) = "caldav-sync-push:$accountName"
|
||||
}
|
||||
}
|
||||
@@ -1,133 +0,0 @@
|
||||
package de.jeanlucmakiola.agendula.data.sync
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.hilt.work.HiltWorker
|
||||
import androidx.work.CoroutineWorker
|
||||
import androidx.work.ForegroundInfo
|
||||
import androidx.work.WorkerParameters
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedInject
|
||||
import de.jeanlucmakiola.agendula.R
|
||||
import de.jeanlucmakiola.agendula.data.reminders.ReminderScheduler
|
||||
|
||||
/**
|
||||
* Where sync actually happens.
|
||||
*
|
||||
* Two things about this worker are decided already. It is a
|
||||
* **`CoroutineWorker` with no foreground service**: an ordinary
|
||||
* worker is documented for under 10 minutes, and escalating to `setForeground`
|
||||
* pulls in `FOREGROUND_SERVICE_DATA_SYNC`, the Android 15 six-hours-per-24
|
||||
* `dataSync` budget whose failure mode is a fatal `RemoteServiceException`, and
|
||||
* a Play requirement for a video demo per declared FGS type. And it must be
|
||||
* **chunked and resumable** — the sync cursor is persisted per collection so a
|
||||
* killed worker resumes rather than restarts, because under WorkManager process
|
||||
* death mid-sync is routine rather than exotic.
|
||||
*/
|
||||
@HiltWorker
|
||||
class SyncWorker @AssistedInject constructor(
|
||||
@Assisted context: Context,
|
||||
@Assisted parameters: WorkerParameters,
|
||||
private val engine: SyncEngine,
|
||||
private val noticeNotifier: SyncNoticeNotifier,
|
||||
private val reminderScheduler: ReminderScheduler,
|
||||
private val accountState: AccountStateStore,
|
||||
) : CoroutineWorker(context, parameters) {
|
||||
|
||||
override suspend fun doWork(): Result {
|
||||
val accountName = inputData.getString(KEY_ACCOUNT_NAME) ?: return Result.failure()
|
||||
|
||||
return when (val outcome = engine.sync(accountName)) {
|
||||
is SyncEngine.Result.Synced -> {
|
||||
// ⚠️ Success even when collections failed. A retry re-runs the
|
||||
// whole account, and WorkManager's backoff would then punish the
|
||||
// four healthy collections for the one that 500s — while the
|
||||
// failing one is already contained by its own quarantine counter.
|
||||
outcome.reports.forEach { report ->
|
||||
if (report.failure != null || report.hadWork) Log.i(TAG, report.toString())
|
||||
}
|
||||
// ⚠️ Said out loud, not only logged. `SyncReport`'s own doc
|
||||
// calls the report "the other half" of server-wins, and this
|
||||
// worker runs on a four-hour timer with the app closed — so a
|
||||
// log line is the same as saying nothing. Only what is new: the
|
||||
// store has already dropped whatever the user has been told.
|
||||
noticeNotifier.post(accountName, outcome.notices)
|
||||
// Nothing else re-arms reminders for what a sync pulled in: in our
|
||||
// own store no provider broadcast fires.
|
||||
if (outcome.reports.any { it.hadWork || it.discardedEdits.isNotEmpty() }) {
|
||||
runCatching { reminderScheduler.sync() }
|
||||
}
|
||||
noticeNotifier.cancelSignIn(accountName)
|
||||
Result.success()
|
||||
}
|
||||
|
||||
// Only the user can fix this, and retrying costs them Nextcloud's
|
||||
// per-IP brute-force throttle — which takes their *other* clients
|
||||
// down with it. Said once per stop, since this runs with the app closed.
|
||||
is SyncEngine.Result.NeedsSignIn -> {
|
||||
if (accountState.markSignInNotified(outcome.accountId)) {
|
||||
noticeNotifier.postSignIn(accountName, outcome.accountId)
|
||||
}
|
||||
Result.failure()
|
||||
}
|
||||
|
||||
is SyncEngine.Result.Misconfigured -> Result.failure()
|
||||
|
||||
// Not a failure: the account is kept, and syncs again once the user
|
||||
// switches back to Agendula's own storage.
|
||||
SyncEngine.Result.Paused -> Result.success()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ⚠️ Implemented **unconditionally**, even though this worker never asks to
|
||||
* run in the foreground.
|
||||
*
|
||||
* `setExpedited` falls back to a foreground service below API 31, and
|
||||
* WorkManager calls this to build it. The default implementation throws
|
||||
* `IllegalStateException`, so a worker that only ever runs expedited on
|
||||
* modern devices crashes on every device running API 29 or 30 — which we
|
||||
* support. It is never actually shown above API 30.
|
||||
*/
|
||||
override suspend fun getForegroundInfo(): ForegroundInfo {
|
||||
val manager = applicationContext.getSystemService(NotificationManager::class.java)
|
||||
manager?.createNotificationChannel(
|
||||
NotificationChannel(
|
||||
CHANNEL_ID,
|
||||
applicationContext.getString(R.string.sync_notification_channel),
|
||||
NotificationManager.IMPORTANCE_LOW,
|
||||
),
|
||||
)
|
||||
|
||||
val notification: Notification = NotificationCompat.Builder(applicationContext, CHANNEL_ID)
|
||||
.setContentTitle(applicationContext.getString(R.string.sync_notification_title))
|
||||
.setSmallIcon(R.drawable.ic_notification)
|
||||
.setOngoing(true)
|
||||
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||
.build()
|
||||
|
||||
// ⚠️ **No `foregroundServiceType`.** Declaring `dataSync` is what drags in
|
||||
// `FOREGROUND_SERVICE_DATA_SYNC`, the Android 15 six-hours-per-24 budget
|
||||
// whose failure mode is a fatal `RemoteServiceException`, and a Play
|
||||
// requirement for a video demo per declared type — the whole tail this
|
||||
// worker exists to avoid. It is not needed either: above API 30
|
||||
// `setExpedited` uses an expedited job and never calls this at all, and
|
||||
// types only became mandatory at API 34.
|
||||
return ForegroundInfo(NOTIFICATION_ID, notification)
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** One in-flight sync per account, so a manual trigger cannot pile up. */
|
||||
fun uniqueNameFor(accountName: String) = "caldav-sync:$accountName"
|
||||
|
||||
const val KEY_ACCOUNT_NAME = "accountName"
|
||||
|
||||
private const val TAG = "SyncWorker"
|
||||
private const val CHANNEL_ID = "sync"
|
||||
private const val NOTIFICATION_ID = 4001
|
||||
}
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
package de.jeanlucmakiola.agendula.data.sync.push
|
||||
|
||||
import android.util.Log
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import de.jeanlucmakiola.agendula.data.di.ApplicationScope
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import org.unifiedpush.android.connector.FailedReason
|
||||
import org.unifiedpush.android.connector.PushService
|
||||
import org.unifiedpush.android.connector.data.PushEndpoint
|
||||
import org.unifiedpush.android.connector.data.PushMessage
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* Where the UnifiedPush distributor reaches us; the instance is the account id.
|
||||
* Work runs in the application scope, since the connector unbinds after a second.
|
||||
*/
|
||||
@AndroidEntryPoint
|
||||
class AgendulaPushService : PushService() {
|
||||
|
||||
@Inject @ApplicationScope lateinit var scope: CoroutineScope
|
||||
|
||||
@Inject lateinit var registrar: PushRegistrar
|
||||
|
||||
@Inject lateinit var messages: PushMessageHandler
|
||||
|
||||
override fun onNewEndpoint(endpoint: PushEndpoint, instance: String) {
|
||||
val accountId = instance.toLongOrNull() ?: return
|
||||
scope.launch { runCatching { registrar.onNewEndpoint(accountId, endpoint) }.onFailure(::log) }
|
||||
}
|
||||
|
||||
override fun onMessage(message: PushMessage, instance: String) {
|
||||
// Encryption is mandatory in the draft.
|
||||
if (!message.decrypted) {
|
||||
Log.w(TAG, "dropped a push message that did not decrypt")
|
||||
return
|
||||
}
|
||||
val content = message.content.toString(Charsets.UTF_8)
|
||||
scope.launch { runCatching { messages.handle(content, instance) }.onFailure(::log) }
|
||||
}
|
||||
|
||||
override fun onRegistrationFailed(reason: FailedReason, instance: String) {
|
||||
Log.w(TAG, "distributor refused registration for $instance: $reason")
|
||||
// A transient failure leaves the last endpoint valid; the next renewal retries.
|
||||
if (reason == FailedReason.NETWORK || reason == FailedReason.INTERNAL_ERROR) return
|
||||
val accountId = instance.toLongOrNull() ?: return
|
||||
scope.launch { runCatching { registrar.onUnregistered(accountId) }.onFailure(::log) }
|
||||
}
|
||||
|
||||
override fun onUnregistered(instance: String) {
|
||||
val accountId = instance.toLongOrNull() ?: return
|
||||
scope.launch { runCatching { registrar.onUnregistered(accountId) }.onFailure(::log) }
|
||||
}
|
||||
|
||||
private fun log(error: Throwable) = Log.w(TAG, "push handling failed", error)
|
||||
|
||||
private companion object {
|
||||
const val TAG = "AgendulaPushService"
|
||||
}
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
package de.jeanlucmakiola.agendula.data.sync.push
|
||||
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.flow.first
|
||||
import org.unifiedpush.android.connector.UnifiedPush
|
||||
import org.unifiedpush.android.connector.data.ResolvedDistributor
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Which UnifiedPush distributor delivers our pushes, and whether push is on. The
|
||||
* connector keeps the choice; the on/off switch lives in [SettingsPrefs].
|
||||
*/
|
||||
@Singleton
|
||||
class PushDistributors @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val settings: SettingsPrefs,
|
||||
) {
|
||||
|
||||
data class Distributor(val packageName: String, val label: String)
|
||||
|
||||
/** The distributor apps installed right now. */
|
||||
fun installed(): List<Distributor> =
|
||||
UnifiedPush.getDistributors(context)
|
||||
.filter { it != context.packageName }
|
||||
.map { Distributor(it, labelOf(it)) }
|
||||
.sortedBy { it.label.lowercase() }
|
||||
|
||||
/**
|
||||
* The distributor to register with, or null when push is off or none is
|
||||
* usable. With no choice saved, a default or sole distributor is adopted.
|
||||
*/
|
||||
suspend fun toUse(): String? {
|
||||
if (!settings.settings.first().pushEnabled) return null
|
||||
UnifiedPush.getSavedDistributor(context)?.let { return it }
|
||||
return when (val resolved = UnifiedPush.resolveDefaultDistributor(context)) {
|
||||
is ResolvedDistributor.Found -> resolved.packageName
|
||||
.takeIf { it != context.packageName }
|
||||
?.also { UnifiedPush.saveDistributor(context, it) }
|
||||
ResolvedDistributor.ToSelect, ResolvedDistributor.NoneAvailable -> null
|
||||
}
|
||||
}
|
||||
|
||||
/** The saved choice, without resolving a default. For display. */
|
||||
fun saved(): String? = UnifiedPush.getSavedDistributor(context)
|
||||
|
||||
suspend fun select(packageName: String) {
|
||||
UnifiedPush.saveDistributor(context, packageName)
|
||||
settings.setPushEnabled(true)
|
||||
}
|
||||
|
||||
/** Turns push off. Every registration with the distributor goes with it. */
|
||||
suspend fun disable() {
|
||||
settings.setPushEnabled(false)
|
||||
UnifiedPush.removeDistributor(context)
|
||||
}
|
||||
|
||||
/**
|
||||
* Unregisters one instance without losing the user's choice, which the
|
||||
* connector drops along with its last instance.
|
||||
*/
|
||||
fun unregister(instance: String) {
|
||||
val chosen = UnifiedPush.getSavedDistributor(context)
|
||||
UnifiedPush.unregister(context, instance)
|
||||
if (chosen != null && UnifiedPush.getSavedDistributor(context) == null) {
|
||||
UnifiedPush.saveDistributor(context, chosen)
|
||||
}
|
||||
}
|
||||
|
||||
fun labelOf(packageName: String): String = try {
|
||||
val info = context.packageManager.getApplicationInfo(packageName, 0)
|
||||
context.packageManager.getApplicationLabel(info).toString()
|
||||
} catch (_: PackageManager.NameNotFoundException) {
|
||||
packageName
|
||||
}
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
package de.jeanlucmakiola.agendula.data.sync.push
|
||||
|
||||
import android.util.Log
|
||||
import de.jeanlucmakiola.agendula.data.di.IoDispatcher
|
||||
import de.jeanlucmakiola.agendula.data.sync.SyncTrigger
|
||||
import de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase
|
||||
import de.jeanlucmakiola.caldav.WebDavPush
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.withContext
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/** Turns a WebDAV-Push message into a sync of the account it is about. */
|
||||
@Singleton
|
||||
class PushMessageHandler @Inject constructor(
|
||||
private val database: TasksDatabase,
|
||||
private val store: PushStore,
|
||||
private val trigger: SyncTrigger,
|
||||
@IoDispatcher private val io: CoroutineDispatcher,
|
||||
) {
|
||||
|
||||
/**
|
||||
* @param content the decrypted message body.
|
||||
* @param instance the UnifiedPush instance, which is the account id.
|
||||
*/
|
||||
suspend fun handle(content: String, instance: String) = withContext(io) {
|
||||
val accountId = instance.toLongOrNull() ?: return@withContext
|
||||
val account = database.accounts().account(accountId) ?: return@withContext
|
||||
val message = WebDavPush.parse(content)
|
||||
|
||||
val topic = message?.topic
|
||||
if (topic != null) {
|
||||
// Within this account: a shared calendar has one topic across accounts.
|
||||
val list = store.all().values
|
||||
.filter { it.support?.topic == topic }
|
||||
.firstNotNullOfOrNull { push ->
|
||||
database.taskLists().entity(push.listId)?.takeIf { it.accountId == accountId }
|
||||
}
|
||||
if (list == null) {
|
||||
Log.i(TAG, "push for a topic no synced list has")
|
||||
return@withContext
|
||||
}
|
||||
// Already at that state, e.g. our own write echoed back.
|
||||
if (message.syncToken != null && message.syncToken == list.syncToken) return@withContext
|
||||
}
|
||||
// Without a topic (key rotation, unreadable): a sync re-reads the VAPID key.
|
||||
trigger.enqueueFromPush(account.displayName)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val TAG = "PushMessageHandler"
|
||||
}
|
||||
}
|
||||
@@ -1,271 +0,0 @@
|
||||
package de.jeanlucmakiola.agendula.data.sync.push
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import androidx.work.BackoffPolicy
|
||||
import androidx.work.Constraints
|
||||
import androidx.work.ExistingPeriodicWorkPolicy
|
||||
import androidx.work.NetworkType
|
||||
import androidx.work.PeriodicWorkRequestBuilder
|
||||
import androidx.work.WorkManager
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import de.jeanlucmakiola.agendula.data.di.IoDispatcher
|
||||
import de.jeanlucmakiola.agendula.data.sync.AccountStateStore
|
||||
import de.jeanlucmakiola.agendula.data.sync.CredentialStore
|
||||
import de.jeanlucmakiola.agendula.data.sync.SyncAvailability
|
||||
import de.jeanlucmakiola.agendula.data.sync.SyncReport
|
||||
import de.jeanlucmakiola.agendula.data.tasks.room.AccountEntity
|
||||
import de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase
|
||||
import de.jeanlucmakiola.caldav.CalDavHttp
|
||||
import de.jeanlucmakiola.caldav.WebDavPush
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import okhttp3.OkHttpClient
|
||||
import org.unifiedpush.android.connector.UnifiedPush
|
||||
import org.unifiedpush.android.connector.data.PushEndpoint
|
||||
import java.util.concurrent.TimeUnit
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
import kotlin.time.Clock
|
||||
import kotlin.time.Duration.Companion.days
|
||||
|
||||
/**
|
||||
* Keeps WebDAV-Push subscriptions in step with the synced lists, modelled on
|
||||
* DAVx5's `PushRegistrationManager`. [update] registers each account with the
|
||||
* distributor; [onNewEndpoint] subscribes the endpoint it answers with. The
|
||||
* daily [PushRenewalWorker] re-registers, which is what renews subscriptions.
|
||||
*/
|
||||
@Singleton
|
||||
class PushRegistrar @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val database: TasksDatabase,
|
||||
private val store: PushStore,
|
||||
private val distributors: PushDistributors,
|
||||
private val credentials: CredentialStore,
|
||||
private val accountState: AccountStateStore,
|
||||
private val availability: SyncAvailability,
|
||||
@IoDispatcher private val io: CoroutineDispatcher,
|
||||
) {
|
||||
|
||||
/** One subscribe/unsubscribe pass at a time, across every entry point. */
|
||||
private val mutex = Mutex()
|
||||
|
||||
/** Records what a sync read about push support, and registers if that changed. */
|
||||
suspend fun onSynced(account: AccountEntity, reports: List<SyncReport>) {
|
||||
val read = reports.filter { it.collectionRead }.associate { it.listId to it.pushSupport }
|
||||
if (store.recordSupport(read)) update(account.id)
|
||||
}
|
||||
|
||||
suspend fun updateAll() = mutex.withLock {
|
||||
withContext(io) {
|
||||
database.accounts().all().forEach { updateAccount(it) }
|
||||
scheduleRenewal()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun update(accountId: Long) = mutex.withLock {
|
||||
withContext(io) {
|
||||
database.accounts().account(accountId)?.let { updateAccount(it) }
|
||||
scheduleRenewal()
|
||||
}
|
||||
}
|
||||
|
||||
/** Our subscription per collection URL, for `Push-Dont-Notify` on the account's writes. */
|
||||
suspend fun subscriptionsByHref(accountId: Long): Map<String, HttpUrl> = withContext(io) {
|
||||
val pushes = store.all()
|
||||
database.taskLists().syncedForAccount(accountId).mapNotNull { list ->
|
||||
// Normalised the way SyncEngine spells the URL it looks up.
|
||||
val href = list.href?.toHttpUrlOrNull()?.toString() ?: return@mapNotNull null
|
||||
val subscription = pushes[list.id]?.subscription?.toHttpUrlOrNull() ?: return@mapNotNull null
|
||||
href to subscription
|
||||
}.toMap()
|
||||
}
|
||||
|
||||
/** The distributor's endpoint for [accountId] is ready: subscribe the account's lists to it. */
|
||||
suspend fun onNewEndpoint(accountId: Long, endpoint: PushEndpoint) = mutex.withLock {
|
||||
withContext(io) {
|
||||
val account = database.accounts().account(accountId) ?: return@withContext
|
||||
if (!pushAllowed(account)) return@withContext
|
||||
val client = clientFor(account) ?: return@withContext
|
||||
|
||||
val pushes = store.all()
|
||||
val lists = database.taskLists().syncedForAccount(account.id)
|
||||
val wanted = lists.filter { it.href != null && pushes[it.id]?.support != null }
|
||||
val renewBefore = Clock.System.now() + RENEW_MARGIN
|
||||
|
||||
for (list in wanted) {
|
||||
val push = pushes.getValue(list.id)
|
||||
val current = push.subscription != null &&
|
||||
push.endpoint == endpoint.url &&
|
||||
push.expires?.let { it > renewBefore } == true
|
||||
if (current) continue
|
||||
|
||||
// A changed endpoint means a new subscription, not an update.
|
||||
if (push.endpoint != null && push.endpoint != endpoint.url) {
|
||||
push.subscription?.toHttpUrlOrNull()?.let { WebDavPush.unregister(client, it) }
|
||||
}
|
||||
val collection = list.href!!.toHttpUrlOrNull() ?: continue
|
||||
when (
|
||||
val outcome = WebDavPush.register(
|
||||
client = client,
|
||||
collection = collection,
|
||||
endpoint = endpoint.url,
|
||||
publicKey = endpoint.pubKeySet?.pubKey,
|
||||
authSecret = endpoint.pubKeySet?.auth,
|
||||
expires = Clock.System.now() + REQUESTED_LIFETIME,
|
||||
)
|
||||
) {
|
||||
is WebDavPush.Registration.Registered -> store.recordSubscription(
|
||||
listId = list.id,
|
||||
subscription = outcome.url?.toString(),
|
||||
endpoint = endpoint.url,
|
||||
expires = outcome.expires,
|
||||
)
|
||||
is WebDavPush.Registration.Refused -> {
|
||||
Log.w(TAG, "push refused for ${list.id}: HTTP ${outcome.code}")
|
||||
store.clearSubscription(list.id)
|
||||
// Stop the account as a sync would; Nextcloud throttles per IP.
|
||||
if (outcome.code == UNAUTHORIZED) {
|
||||
accountState.setNeedsSignIn(account.id, true)
|
||||
return@withContext
|
||||
}
|
||||
}
|
||||
// Retried by the next renewal.
|
||||
is WebDavPush.Registration.Failed -> Log.w(TAG, "push registration failed for ${list.id}: ${outcome.reason}")
|
||||
}
|
||||
}
|
||||
|
||||
// A list that lost push support still holds a subscription nobody wants.
|
||||
val wantedIds = wanted.map { it.id }.toSet()
|
||||
val stale = lists.filter { it.id !in wantedIds && pushes[it.id]?.subscription != null }
|
||||
unsubscribe(client, stale.map { it.id }, pushes)
|
||||
}
|
||||
}
|
||||
|
||||
/** The distributor dropped [accountId]'s registration: its subscriptions lead nowhere. */
|
||||
suspend fun onUnregistered(accountId: Long) = mutex.withLock {
|
||||
withContext(io) {
|
||||
database.accounts().account(accountId)?.let { unsubscribeAll(it) }
|
||||
}
|
||||
}
|
||||
|
||||
/** Before an account is removed, while its credential still works. */
|
||||
suspend fun forgetAccount(accountId: Long) = mutex.withLock {
|
||||
withContext(io) {
|
||||
val account = database.accounts().account(accountId) ?: return@withContext
|
||||
unsubscribeAll(account)
|
||||
distributors.unregister(accountId.toString())
|
||||
store.forget(database.taskLists().syncedForAccount(accountId).map { it.id }.toSet())
|
||||
}
|
||||
}
|
||||
|
||||
/** Before lists stop syncing with [accountId]. */
|
||||
suspend fun forgetLists(accountId: Long, listIds: Set<Long>) = mutex.withLock {
|
||||
withContext(io) {
|
||||
if (listIds.isEmpty()) return@withContext
|
||||
val account = database.accounts().account(accountId)
|
||||
val client = account?.let { clientFor(it) }
|
||||
val pushes = store.all()
|
||||
if (client != null) unsubscribe(client, listIds.toList(), pushes)
|
||||
store.forget(listIds)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun updateAccount(account: AccountEntity) {
|
||||
val instance = account.id.toString()
|
||||
val distributor = if (pushAllowed(account)) distributors.toUse() else null
|
||||
val pushes = store.all()
|
||||
val capable = database.taskLists().syncedForAccount(account.id)
|
||||
.mapNotNull { pushes[it.id]?.support }
|
||||
|
||||
if (distributor == null || capable.isEmpty()) {
|
||||
// Not unregistered: the connector would forget the user's distributor.
|
||||
unsubscribeAll(account)
|
||||
return
|
||||
}
|
||||
|
||||
val vapid = capable.firstNotNullOfOrNull { it.vapidPublicKey }
|
||||
try {
|
||||
UnifiedPush.register(context, instance, account.displayName, vapid)
|
||||
} catch (_: UnifiedPush.VapidNotValidException) {
|
||||
Log.w(TAG, "server VAPID key for ${account.id} is not usable")
|
||||
UnifiedPush.register(context, instance, account.displayName, null)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun unsubscribeAll(account: AccountEntity) {
|
||||
val pushes = store.all()
|
||||
val held = database.taskLists().syncedForAccount(account.id)
|
||||
.filter { pushes[it.id]?.subscription != null }
|
||||
.map { it.id }
|
||||
if (held.isEmpty()) return
|
||||
val client = clientFor(account)
|
||||
if (client != null) {
|
||||
unsubscribe(client, held, pushes)
|
||||
} else {
|
||||
// Without a credential they are left to expire.
|
||||
held.forEach { store.clearSubscription(it) }
|
||||
}
|
||||
}
|
||||
|
||||
/** Tells the server, then forgets locally; after the first failure only forgets. */
|
||||
private suspend fun unsubscribe(client: OkHttpClient, listIds: List<Long>, pushes: Map<Long, PushStore.ListPush>) {
|
||||
var reachable = true
|
||||
listIds.forEach { listId ->
|
||||
val subscription = pushes[listId]?.subscription?.toHttpUrlOrNull()
|
||||
if (reachable && subscription != null) reachable = WebDavPush.unregister(client, subscription)
|
||||
store.clearSubscription(listId)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun pushAllowed(account: AccountEntity): Boolean =
|
||||
availability.accountsUsable() && !accountState.needsSignIn(account.id)
|
||||
|
||||
/** Null for an account waiting on sign-in, as in `SyncEngine.sync`. */
|
||||
private suspend fun clientFor(account: AccountEntity): OkHttpClient? {
|
||||
if (accountState.needsSignIn(account.id)) return null
|
||||
val username = account.username ?: return null
|
||||
val origin = account.principalUrl?.toHttpUrlOrNull() ?: return null
|
||||
val password = (credentials.get(account.id) as? CredentialStore.Secret.Present)?.value ?: return null
|
||||
return CalDavHttp.authenticated(USER_AGENT, username, password, origin)
|
||||
.newBuilder()
|
||||
.callTimeout(CALL_TIMEOUT_SECONDS, TimeUnit.SECONDS)
|
||||
.build()
|
||||
}
|
||||
|
||||
/** Only while some list could be pushed; nothing to renew otherwise. */
|
||||
private suspend fun scheduleRenewal() {
|
||||
val work = WorkManager.getInstance(context)
|
||||
val needed = distributors.toUse() != null && store.all().values.any { it.support != null }
|
||||
if (!needed) {
|
||||
work.cancelUniqueWork(PushRenewalWorker.NAME)
|
||||
return
|
||||
}
|
||||
val request = PeriodicWorkRequestBuilder<PushRenewalWorker>(RENEWAL_INTERVAL_DAYS, TimeUnit.DAYS)
|
||||
.setConstraints(Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build())
|
||||
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 1, TimeUnit.MINUTES)
|
||||
.build()
|
||||
work.enqueueUniquePeriodicWork(PushRenewalWorker.NAME, ExistingPeriodicWorkPolicy.KEEP, request)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val TAG = "PushRegistrar"
|
||||
const val USER_AGENT = "Agendula (Android)"
|
||||
const val UNAUTHORIZED = 401
|
||||
|
||||
/** A registration is one small request; removal waits on it. */
|
||||
const val CALL_TIMEOUT_SECONDS = 15L
|
||||
|
||||
/** What we ask for; the draft recommends at least three days. */
|
||||
val REQUESTED_LIFETIME = 3.days
|
||||
|
||||
const val RENEWAL_INTERVAL_DAYS = 1L
|
||||
|
||||
/** Two renewal intervals, since periodic work is not punctual. */
|
||||
val RENEW_MARGIN = (2 * RENEWAL_INTERVAL_DAYS).days
|
||||
}
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
package de.jeanlucmakiola.agendula.data.sync.push
|
||||
|
||||
import android.content.Context
|
||||
import androidx.hilt.work.HiltWorker
|
||||
import androidx.work.CoroutineWorker
|
||||
import androidx.work.WorkerParameters
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedInject
|
||||
|
||||
/** Re-registers every account daily, which renews the subscriptions; see [PushRegistrar]. */
|
||||
@HiltWorker
|
||||
class PushRenewalWorker @AssistedInject constructor(
|
||||
@Assisted context: Context,
|
||||
@Assisted parameters: WorkerParameters,
|
||||
private val registrar: PushRegistrar,
|
||||
) : CoroutineWorker(context, parameters) {
|
||||
|
||||
override suspend fun doWork(): Result {
|
||||
registrar.updateAll()
|
||||
return Result.success()
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val NAME = "push-renewal"
|
||||
}
|
||||
}
|
||||
@@ -1,120 +0,0 @@
|
||||
package de.jeanlucmakiola.agendula.data.sync.push
|
||||
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.stringSetPreferencesKey
|
||||
import de.jeanlucmakiola.agendula.data.di.SyncStateDataStore
|
||||
import de.jeanlucmakiola.caldav.PushSupport
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.map
|
||||
import java.net.URLDecoder
|
||||
import java.net.URLEncoder
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
* Per synced list: what its server offers for push, and our subscription there.
|
||||
* Kept out of Room and backups, since a subscription names this device's endpoint.
|
||||
*/
|
||||
@Singleton
|
||||
class PushStore @Inject constructor(
|
||||
@SyncStateDataStore private val dataStore: DataStore<Preferences>,
|
||||
) {
|
||||
|
||||
data class ListPush(
|
||||
val listId: Long,
|
||||
/** Null when the server offers no push for the list. */
|
||||
val support: PushSupport? = null,
|
||||
/** Where our subscription lives on the server; null when there is none. */
|
||||
val subscription: String? = null,
|
||||
/** The push endpoint [subscription] was registered for. */
|
||||
val endpoint: String? = null,
|
||||
val expires: Instant? = null,
|
||||
)
|
||||
|
||||
suspend fun all(): Map<Long, ListPush> = decode(dataStore.data.first()[KEY].orEmpty())
|
||||
|
||||
fun observe(): Flow<Map<Long, ListPush>> = dataStore.data.map { decode(it[KEY].orEmpty()) }
|
||||
|
||||
/**
|
||||
* Records what the last sync read about each list's push support.
|
||||
*
|
||||
* @return whether anything changed, which is what makes a registration due.
|
||||
*/
|
||||
suspend fun recordSupport(support: Map<Long, PushSupport?>): Boolean {
|
||||
if (support.isEmpty()) return false
|
||||
var changed = false
|
||||
update { current ->
|
||||
support.forEach { (listId, found) ->
|
||||
val before = current[listId] ?: ListPush(listId)
|
||||
if (before.support != found) {
|
||||
changed = true
|
||||
current[listId] = before.copy(support = found)
|
||||
}
|
||||
}
|
||||
}
|
||||
return changed
|
||||
}
|
||||
|
||||
suspend fun recordSubscription(listId: Long, subscription: String?, endpoint: String?, expires: Instant?) =
|
||||
update { current ->
|
||||
val before = current[listId] ?: ListPush(listId)
|
||||
current[listId] = before.copy(subscription = subscription, endpoint = endpoint, expires = expires)
|
||||
}
|
||||
|
||||
suspend fun clearSubscription(listId: Long) = recordSubscription(listId, null, null, null)
|
||||
|
||||
suspend fun forget(listIds: Set<Long>) {
|
||||
if (listIds.isEmpty()) return
|
||||
update { current -> listIds.forEach(current::remove) }
|
||||
}
|
||||
|
||||
private suspend fun update(change: (MutableMap<Long, ListPush>) -> Unit) {
|
||||
dataStore.edit { prefs ->
|
||||
// Re-read inside `edit`, which DataStore serialises.
|
||||
val current = decode(prefs[KEY].orEmpty()).toMutableMap()
|
||||
change(current)
|
||||
prefs[KEY] = current.values
|
||||
.filter { it.support != null || it.subscription != null }
|
||||
.map(::encode)
|
||||
.toSet()
|
||||
}
|
||||
}
|
||||
|
||||
private fun encode(push: ListPush): String = listOf(
|
||||
push.listId.toString(),
|
||||
push.support?.topic,
|
||||
push.support?.vapidPublicKey,
|
||||
push.subscription,
|
||||
push.endpoint,
|
||||
push.expires?.epochSeconds?.toString(),
|
||||
).joinToString(SEPARATOR) { it?.let(::escape).orEmpty() }
|
||||
|
||||
private fun decode(entries: Set<String>): Map<Long, ListPush> =
|
||||
entries.mapNotNull { entry ->
|
||||
val parts = entry.split(SEPARATOR).map { part -> part.takeIf { it.isNotEmpty() }?.let(::unescape) }
|
||||
if (parts.size != FIELDS) return@mapNotNull null
|
||||
val listId = parts[0]?.toLongOrNull() ?: return@mapNotNull null
|
||||
listId to ListPush(
|
||||
listId = listId,
|
||||
support = parts[1]?.let { PushSupport(topic = it, vapidPublicKey = parts[2]) },
|
||||
subscription = parts[3],
|
||||
endpoint = parts[4],
|
||||
expires = parts[5]?.toLongOrNull()?.let(Instant::fromEpochSeconds),
|
||||
)
|
||||
}.toMap()
|
||||
|
||||
// Server-chosen strings, escaped so none contains the separator.
|
||||
private fun escape(value: String): String = URLEncoder.encode(value, "UTF-8")
|
||||
|
||||
private fun unescape(value: String): String = URLDecoder.decode(value, "UTF-8")
|
||||
|
||||
private companion object {
|
||||
val KEY = stringSetPreferencesKey("push_lists")
|
||||
const val SEPARATOR = "|"
|
||||
const val FIELDS = 6
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package de.jeanlucmakiola.agendula.data.tasks
|
||||
|
||||
import android.content.ContentProviderOperation
|
||||
import android.content.ContentResolver
|
||||
import android.content.ContentUris
|
||||
import android.content.ContentValues
|
||||
@@ -19,6 +20,7 @@ import de.jeanlucmakiola.agendula.domain.Task
|
||||
import de.jeanlucmakiola.agendula.domain.TaskForm
|
||||
import de.jeanlucmakiola.agendula.domain.TaskList
|
||||
import de.jeanlucmakiola.agendula.domain.export.ExportTask
|
||||
import de.jeanlucmakiola.agendula.domain.normalizeTags
|
||||
import de.jeanlucmakiola.agendula.domain.recurrence.ExpansionWindow
|
||||
import de.jeanlucmakiola.agendula.domain.recurrence.RecurrenceExpander
|
||||
import de.jeanlucmakiola.agendula.domain.recurrence.RecurrenceSpec
|
||||
@@ -90,11 +92,14 @@ class AndroidTasksDataSource @Inject constructor(
|
||||
// `is_recurring` / `distance_from_current`). The by-name mapper reads
|
||||
// whatever is present and nulls the rest; the repository re-sorts, so we
|
||||
// don't depend on a provider sort column either.
|
||||
return resolver.query(uri, null, selection, args, null)
|
||||
val tasks = resolver.query(uri, null, selection, args, null)
|
||||
?.use { c ->
|
||||
val reader = CursorColumnReader(c)
|
||||
buildList { while (c.moveToNext()) add(TaskMapper.task(reader)) }
|
||||
} ?: emptyList()
|
||||
if (tasks.isEmpty()) return tasks
|
||||
val tags = categories(tasks.map { it.taskId })
|
||||
return tasks.map { task -> tags[task.taskId]?.let { task.copy(tags = it) } ?: task }
|
||||
}
|
||||
|
||||
override fun exportTasks(listId: Long): List<ExportTask> {
|
||||
@@ -102,7 +107,7 @@ class AndroidTasksDataSource @Inject constructor(
|
||||
// table's shape varies across provider versions, and the by-name mapper
|
||||
// reads what's there.
|
||||
val uri = TasksContract.tasksUri(authority())
|
||||
return resolver.query(
|
||||
val tasks = resolver.query(
|
||||
uri,
|
||||
null,
|
||||
// _deleted marks a row awaiting a sync round-trip. It's gone as far as
|
||||
@@ -115,21 +120,39 @@ class AndroidTasksDataSource @Inject constructor(
|
||||
val reader = CursorColumnReader(c)
|
||||
buildList { while (c.moveToNext()) add(TaskMapper.exportTask(reader)) }
|
||||
} ?: emptyList()
|
||||
if (tasks.isEmpty()) return tasks
|
||||
val tags = categories(tasks.map { it.taskId })
|
||||
return tasks.map { task -> tags[task.taskId]?.let { task.copy(tags = it) } ?: task }
|
||||
}
|
||||
|
||||
// --- writes ---------------------------------------------------------------
|
||||
|
||||
override fun insertTask(form: TaskForm): Long {
|
||||
val values = TaskWriteMapper.seriesValues(form, ZoneId.systemDefault().id)
|
||||
val uri = resolver.insert(TasksContract.tasksUri(authority()), values.toContentValues())
|
||||
?: throw TaskWriteFailedException("insert task")
|
||||
return uri.lastPathSegment?.toLongOrNull() ?: throw TaskWriteFailedException("insert task: no id")
|
||||
// One batch, so a refused tag leaves no task behind for a retry to duplicate.
|
||||
val authority = authority()
|
||||
val ops = arrayListOf(
|
||||
ContentProviderOperation.newInsert(TasksContract.tasksUri(authority))
|
||||
.withValues(values.toContentValues())
|
||||
.build(),
|
||||
)
|
||||
normalizeTags(form.tags).forEach { tag ->
|
||||
ops += ContentProviderOperation.newInsert(TasksContract.propertiesUri(authority))
|
||||
.withValues(TaskWriteMapper.categoryValues(0, tag).toContentValues().apply { remove(Properties.TASK_ID) })
|
||||
.withValueBackReference(Properties.TASK_ID, 0)
|
||||
.build()
|
||||
}
|
||||
val results = runCatching { resolver.applyBatch(authority, ops) }
|
||||
.getOrElse { throw TaskWriteFailedException("insert task", it) }
|
||||
return results.firstOrNull()?.uri?.lastPathSegment?.toLongOrNull()
|
||||
?: throw TaskWriteFailedException("insert task: no id")
|
||||
}
|
||||
|
||||
override fun updateTask(taskId: Long, form: TaskForm) {
|
||||
val values = TaskWriteMapper.seriesValues(form, ZoneId.systemDefault().id)
|
||||
val rows = resolver.update(taskUri(authority(), taskId), values.toContentValues(), null, null)
|
||||
if (rows == 0) throw TaskWriteFailedException("update task $taskId")
|
||||
setCategories(taskId, form.tags)
|
||||
}
|
||||
|
||||
override fun updateSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm) {
|
||||
@@ -151,6 +174,7 @@ class AndroidTasksDataSource @Inject constructor(
|
||||
(Tasks.DUE to shifted(anchors?.second, occurrence?.due, form.due))
|
||||
val rows = resolver.update(taskUri(authority(), seriesId), values.toContentValues(), null, null)
|
||||
if (rows == 0) throw TaskWriteFailedException("update series $seriesId")
|
||||
setCategories(seriesId, form.tags)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -243,8 +267,28 @@ class AndroidTasksDataSource @Inject constructor(
|
||||
?: throw TaskWriteFailedException("update instance $taskId@$occurrenceStart: no such occurrence")
|
||||
val values = TaskWriteMapper.instanceValues(form, ZoneId.systemDefault().id)
|
||||
val uri = TasksContract.instanceUri(authority(), instanceId)
|
||||
val rows = resolver.update(uri, values.toContentValues(), null, null)
|
||||
if (rows == 0) throw TaskWriteFailedException("update instance $instanceId")
|
||||
// The fork copies the series' properties onto the override, so the tags
|
||||
// go onto the series first and are put back once the override exists.
|
||||
// Unchanged tags are inherited as they are, and the series is left alone.
|
||||
val seriesTags = categories(listOf(taskId))[taskId].orEmpty()
|
||||
val retag = normalizeTags(form.tags) != seriesTags
|
||||
if (retag) setCategories(taskId, form.tags)
|
||||
var failure: Throwable? = null
|
||||
try {
|
||||
val rows = resolver.update(uri, values.toContentValues(), null, null)
|
||||
if (rows == 0) throw TaskWriteFailedException("update instance $instanceId")
|
||||
} catch (e: Throwable) {
|
||||
failure = e
|
||||
throw e
|
||||
} finally {
|
||||
if (retag) {
|
||||
try {
|
||||
setCategories(taskId, seriesTags)
|
||||
} catch (e: Throwable) {
|
||||
failure?.addSuppressed(e) ?: throw e
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -348,6 +392,58 @@ class AndroidTasksDataSource @Inject constructor(
|
||||
} ?: emptyList()
|
||||
}
|
||||
|
||||
/** Tags by task id for [taskIds], in stored order. */
|
||||
private fun categories(taskIds: Collection<Long>): Map<Long, List<String>> {
|
||||
val rows = taskIds.distinct().chunked(MAX_SQL_ARGS - 1).flatMap { chunk ->
|
||||
val selection = "${Properties.MIMETYPE} = ? AND ${Properties.TASK_ID} IN (${chunk.joinToString(",") { "?" }})"
|
||||
val args = (listOf(TasksContract.Category.MIMETYPE) + chunk.map(Long::toString)).toTypedArray()
|
||||
resolver.query(
|
||||
TasksContract.propertiesUri(authority()),
|
||||
arrayOf(Properties.TASK_ID, TasksContract.Category.NAME),
|
||||
selection,
|
||||
args,
|
||||
Properties.PROPERTY_ID,
|
||||
)?.use { c ->
|
||||
val reader = CursorColumnReader(c)
|
||||
buildList {
|
||||
while (c.moveToNext()) {
|
||||
val id = reader.getLong(Properties.TASK_ID) ?: continue
|
||||
val name = reader.getString(TasksContract.Category.NAME) ?: continue
|
||||
add(id to name)
|
||||
}
|
||||
}
|
||||
} ?: emptyList()
|
||||
}
|
||||
return rows.groupBy({ it.first }, { it.second }).mapValues { normalizeTags(it.value) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces [taskId]'s category rows in one batch, so a refused insert cannot
|
||||
* leave half the tags, and leaves them alone when nothing changed so the task
|
||||
* isn't dirtied.
|
||||
*/
|
||||
private fun setCategories(taskId: Long, tags: List<String>) {
|
||||
val wanted = normalizeTags(tags)
|
||||
if (categories(listOf(taskId))[taskId].orEmpty() == wanted) return
|
||||
val authority = authority()
|
||||
val uri = TasksContract.propertiesUri(authority)
|
||||
val ops = arrayListOf(
|
||||
ContentProviderOperation.newDelete(uri)
|
||||
.withSelection(
|
||||
"${Properties.TASK_ID} = ? AND ${Properties.MIMETYPE} = ?",
|
||||
arrayOf(taskId.toString(), TasksContract.Category.MIMETYPE),
|
||||
)
|
||||
.build(),
|
||||
)
|
||||
wanted.forEach {
|
||||
ops += ContentProviderOperation.newInsert(uri)
|
||||
.withValues(TaskWriteMapper.categoryValues(taskId, it).toContentValues())
|
||||
.build()
|
||||
}
|
||||
runCatching { resolver.applyBatch(authority, ops) }
|
||||
.getOrElse { throw TaskWriteFailedException("set categories for task $taskId", it) }
|
||||
}
|
||||
|
||||
override fun setCancelled(taskId: Long, cancelled: Boolean) {
|
||||
val rows = resolver.update(taskUri(authority(), taskId), cancelValues(cancelled).toContentValues(), null, null)
|
||||
if (rows == 0) throw TaskWriteFailedException("cancel task $taskId")
|
||||
@@ -460,3 +556,6 @@ class AndroidTasksDataSource @Inject constructor(
|
||||
return cv
|
||||
}
|
||||
}
|
||||
|
||||
/** SQLite's default bound-argument limit on older Android releases. */
|
||||
private const val MAX_SQL_ARGS = 999
|
||||
@@ -5,8 +5,8 @@ class ProviderUnavailableException :
|
||||
IllegalStateException("No tasks provider installed")
|
||||
|
||||
/** A ContentResolver write returned no URI or affected no rows. */
|
||||
class TaskWriteFailedException(operation: String) :
|
||||
RuntimeException("Task write failed: $operation")
|
||||
class TaskWriteFailedException(operation: String, cause: Throwable? = null) :
|
||||
RuntimeException("Task write failed: $operation", cause)
|
||||
|
||||
/**
|
||||
* The task changed (e.g. a DAVx5 sync or another app) since the edit form loaded
|
||||
|
||||
@@ -113,6 +113,12 @@ object TaskWriteMapper {
|
||||
Alarm.ALARM_TYPE to Alarm.TYPE_MESSAGE,
|
||||
)
|
||||
|
||||
fun categoryValues(taskId: Long, name: String): Map<String, Any?> = mapOf(
|
||||
Properties.TASK_ID to taskId,
|
||||
Properties.MIMETYPE to TasksContract.Category.MIMETYPE,
|
||||
TasksContract.Category.NAME to name,
|
||||
)
|
||||
|
||||
/** The user-owned columns of a list — what an edit is allowed to change. */
|
||||
fun listValues(name: String, color: Int): Map<String, Any?> = mapOf(
|
||||
Lists.NAME to name.trim(),
|
||||
|
||||
@@ -154,6 +154,18 @@ object TasksContract {
|
||||
const val TYPE_MESSAGE = 1
|
||||
}
|
||||
|
||||
/**
|
||||
* A category property row — one tag. DAVx5 maps these to and from
|
||||
* `CATEGORIES`. Inserting with only [NAME] is enough: the provider looks the
|
||||
* category up in the task's account, or creates it, and fills in `data0`.
|
||||
*/
|
||||
object Category {
|
||||
const val MIMETYPE = "vnd.android.cursor.item/category"
|
||||
|
||||
/** `data1` — the tag as spelled. */
|
||||
const val NAME = "data1"
|
||||
}
|
||||
|
||||
fun propertiesUri(authority: String): Uri = "content://$authority/${Properties.PATH}".toUri()
|
||||
|
||||
// --- status values (TaskColumns.STATUS_*) --------------------------------
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package de.jeanlucmakiola.agendula.data.tasks
|
||||
|
||||
import de.jeanlucmakiola.agendula.domain.SavedFilter
|
||||
import de.jeanlucmakiola.agendula.domain.Task
|
||||
import de.jeanlucmakiola.agendula.domain.TaskDetail
|
||||
import de.jeanlucmakiola.agendula.domain.TaskFilter
|
||||
@@ -13,6 +14,9 @@ import kotlin.time.Instant
|
||||
/** Whether Agendula can use the tasks provider right now. Drives onboarding. */
|
||||
enum class ProviderStatus { READY, NEEDS_PERMISSION, NO_PROVIDER }
|
||||
|
||||
/** Tasks for a filter; [savedFilter] is a saved filter's definition, `null` once it's deleted. */
|
||||
data class FilteredTasks(val tasks: List<Task>, val savedFilter: SavedFilter? = null)
|
||||
|
||||
/**
|
||||
* The single entry point the UI layer uses. Flows re-emit automatically when the
|
||||
* provider changes (our writes *and* external sync like DAVx5), via the
|
||||
@@ -27,7 +31,10 @@ interface TasksRepository {
|
||||
*/
|
||||
fun writableTaskLists(): Flow<List<TaskList>> =
|
||||
taskLists().map { lists -> lists.filter { it.acceptsWrites } }
|
||||
fun tasks(filter: TaskFilter): Flow<List<Task>>
|
||||
fun tasks(filter: TaskFilter): Flow<List<Task>> = filteredTasks(filter).map { it.tasks }
|
||||
|
||||
/** [tasks] together with the definition behind a [TaskFilter.Saved]. */
|
||||
fun filteredTasks(filter: TaskFilter): Flow<FilteredTasks>
|
||||
|
||||
/**
|
||||
* The direct children of [parentId], re-emitting live like the other flows.
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package de.jeanlucmakiola.agendula.data.tasks
|
||||
|
||||
import de.jeanlucmakiola.agendula.data.di.IoDispatcher
|
||||
import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.floret.time.DayWindow
|
||||
import de.jeanlucmakiola.agendula.domain.SavedFilter
|
||||
import de.jeanlucmakiola.agendula.domain.SeriesCollapse
|
||||
import de.jeanlucmakiola.agendula.domain.Task
|
||||
import de.jeanlucmakiola.agendula.domain.TaskDetail
|
||||
@@ -17,7 +19,10 @@ import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.channels.awaitClose
|
||||
import kotlinx.coroutines.channels.consumeEach
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.callbackFlow
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
@@ -32,12 +37,26 @@ class TasksRepositoryImpl @Inject constructor(
|
||||
private val dataSource: TasksDataSource,
|
||||
private val providerResolver: ProviderResolver,
|
||||
private val startupGate: StartupGate,
|
||||
private val settingsPrefs: SettingsPrefs,
|
||||
@IoDispatcher private val io: CoroutineDispatcher,
|
||||
) : TasksRepository {
|
||||
|
||||
override fun taskLists(): Flow<List<TaskList>> = observing { dataSource.taskLists() }
|
||||
|
||||
override fun tasks(filter: TaskFilter): Flow<List<Task>> = observing { loadTasks(filter) }
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
override fun filteredTasks(filter: TaskFilter): Flow<FilteredTasks> = when (filter) {
|
||||
// A deleted filter shows nothing rather than failing the screen or widget on it.
|
||||
is TaskFilter.Saved -> settingsPrefs.savedFilter(filter.id)
|
||||
.flatMapLatest { saved ->
|
||||
if (saved == null) flowOf(FilteredTasks(emptyList()))
|
||||
else observing {
|
||||
// A deleted list's id would otherwise leave the filter quietly empty.
|
||||
val current = saved.withListsIn(dataSource.taskLists().mapTo(HashSet()) { it.id })
|
||||
FilteredTasks(loadTasks(filter, current), current)
|
||||
}
|
||||
}
|
||||
else -> observing { FilteredTasks(loadTasks(filter)) }
|
||||
}
|
||||
|
||||
override fun subtasks(parentId: Long): Flow<List<Task>> = observing {
|
||||
SeriesCollapse.visible(dataSource.subtasks(parentId))
|
||||
@@ -55,7 +74,7 @@ class TasksRepositoryImpl @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadTasks(filter: TaskFilter): List<Task> {
|
||||
private fun loadTasks(filter: TaskFilter, saved: SavedFilter? = null): List<Task> {
|
||||
val query = when (filter) {
|
||||
is TaskFilter.OfList -> TaskQuery(listId = filter.listId, includeCompleted = true)
|
||||
// Read completed tasks too, then let [TaskFiltering.matches] enforce each
|
||||
@@ -63,6 +82,11 @@ class TasksRepositoryImpl @Inject constructor(
|
||||
// parent's subtask-progress reflects ALL its children — completed ones
|
||||
// included — even on smart lists that hide completed tasks themselves.
|
||||
is TaskFilter.Smart -> TaskQuery(includeCompleted = true)
|
||||
// Same over-read as Smart: TaskQuery.listId only narrows to one list, so
|
||||
// several lists are read in full and membership is filtered in-memory.
|
||||
is TaskFilter.OfLists -> TaskQuery(includeCompleted = true)
|
||||
is TaskFilter.Tagged -> TaskQuery(includeCompleted = true)
|
||||
is TaskFilter.Saved -> TaskQuery(includeCompleted = true)
|
||||
}
|
||||
val (todayStart, todayEnd) = DayWindow.today(Clock.System.now(), ZoneId.systemDefault())
|
||||
val all = SeriesCollapse.visible(dataSource.tasks(query))
|
||||
@@ -74,7 +98,7 @@ class TasksRepositoryImpl @Inject constructor(
|
||||
(if (t.isCompleted) done + 1 else done) to (total + 1)
|
||||
}
|
||||
return all
|
||||
.filter { TaskFiltering.matches(it, filter, todayStart, todayEnd) }
|
||||
.filter { TaskFiltering.matches(it, filter, todayStart, todayEnd, saved) }
|
||||
.map { task ->
|
||||
progress[task.taskId]?.let { (done, total) ->
|
||||
task.copy(subtaskDone = done, subtaskTotal = total)
|
||||
@@ -109,6 +133,7 @@ class TasksRepositoryImpl @Inject constructor(
|
||||
parentId = parentId,
|
||||
location = task.location,
|
||||
url = task.url,
|
||||
tags = task.tags,
|
||||
),
|
||||
)
|
||||
val reminders = dataSource.reminders()[task.taskId].orEmpty()
|
||||
|
||||
@@ -9,6 +9,7 @@ import de.jeanlucmakiola.agendula.domain.ical.ICalParser
|
||||
import de.jeanlucmakiola.agendula.domain.ical.ICalProperty
|
||||
import de.jeanlucmakiola.agendula.domain.ical.ICalSerializer
|
||||
import de.jeanlucmakiola.agendula.domain.ical.ICalValues
|
||||
import de.jeanlucmakiola.agendula.domain.normalizeTags
|
||||
import java.time.ZoneId
|
||||
import kotlin.time.Clock
|
||||
import kotlin.time.Instant
|
||||
@@ -81,6 +82,8 @@ object VTodoMapper {
|
||||
// Parameterised copies stay in the residue verbatim; authoring the
|
||||
// column beside them would emit the same exclusions twice.
|
||||
"RDATE", "EXDATE",
|
||||
// Several lines, or one the column would not re-serialise exactly.
|
||||
"CATEGORIES",
|
||||
)
|
||||
|
||||
/** What a VTODO yields. Row identity ([TaskEntity.id], `listId`) is the caller's. */
|
||||
@@ -200,6 +203,9 @@ object VTodoMapper {
|
||||
CLASS_NAMES.indexOf(classProperty?.value?.trim()?.uppercase()).takeIf { it >= 0 },
|
||||
)
|
||||
|
||||
val (categories, categoriesProperty) = readCategories(vtodo)
|
||||
claim(categoriesProperty)
|
||||
|
||||
val sequenceProperty = vtodo.property("SEQUENCE")
|
||||
val sequence = take(
|
||||
sequenceProperty,
|
||||
@@ -225,6 +231,7 @@ object VTodoMapper {
|
||||
title = take(vtodo.property("SUMMARY"), vtodo.property("SUMMARY")?.text()),
|
||||
description = take(vtodo.property("DESCRIPTION"), vtodo.property("DESCRIPTION")?.text()),
|
||||
location = take(vtodo.property("LOCATION"), vtodo.property("LOCATION")?.text()),
|
||||
categories = categories,
|
||||
// URI, not TEXT — escaping it would corrupt a query string.
|
||||
url = take(vtodo.property("URL"), vtodo.property("URL")?.value?.trim()),
|
||||
status = status,
|
||||
@@ -284,8 +291,9 @@ object VTodoMapper {
|
||||
now: Instant = Clock.System.now(),
|
||||
): ICalComponent {
|
||||
val residue = parseResidue(entity.unknownProperties)
|
||||
val residueCategories = normalizeTags(residue.properties("CATEGORIES").flatMap(::categoryValues))
|
||||
val keptResidue =
|
||||
residue.properties.filterNot { contradictsResidue(it, residue, entity, parentUid) }
|
||||
residue.properties.filterNot { contradictsResidue(it, residue, residueCategories, entity, parentUid) }
|
||||
val suppressed = keptResidue
|
||||
.map { it.name.uppercase() }
|
||||
.filterTo(mutableSetOf()) { it in SUPPRESSED_BY_RESIDUE }
|
||||
@@ -307,6 +315,7 @@ object VTodoMapper {
|
||||
add("DESCRIPTION", entity.description?.let(ICalValues::escapeText))
|
||||
add("LOCATION", entity.location?.let(ICalValues::escapeText))
|
||||
add("URL", entity.url)
|
||||
add("CATEGORIES", normalizeTags(entity.categories).takeIf { it.isNotEmpty() }?.let(ICalValues::formatTextList))
|
||||
|
||||
add("STATUS", entity.status.toICalName())
|
||||
add("PERCENT-COMPLETE", entity.percentComplete?.toString())
|
||||
@@ -386,6 +395,7 @@ object VTodoMapper {
|
||||
private fun contradictsResidue(
|
||||
property: ICalProperty,
|
||||
residue: ICalComponent,
|
||||
residueCategories: List<String>,
|
||||
entity: TaskEntity,
|
||||
parentUid: String?,
|
||||
): Boolean {
|
||||
@@ -433,6 +443,7 @@ object VTodoMapper {
|
||||
"PRIORITY" -> entity.priority != PRIORITY_NONE
|
||||
"PERCENT-COMPLETE" -> entity.percentComplete != null
|
||||
"CLASS" -> entity.classification != null
|
||||
"CATEGORIES" -> entity.categories != residueCategories
|
||||
// Not SEQUENCE: it is the organiser's counter and never ours to bump,
|
||||
// so the column stays at its fallback and the residue always wins.
|
||||
else -> false
|
||||
@@ -475,6 +486,23 @@ object VTodoMapper {
|
||||
|
||||
// -------------------------------------------------------------- helpers
|
||||
|
||||
/**
|
||||
* Every `CATEGORIES` copy's values, merged, and the one line to claim — only
|
||||
* when it is the sole, parameterless copy and the column re-serialises it
|
||||
* byte for byte. Anything else is read for display and stays in the residue.
|
||||
*/
|
||||
private fun readCategories(vtodo: ICalComponent): Pair<List<String>, ICalProperty?> {
|
||||
val copies = vtodo.properties("CATEGORIES")
|
||||
val values = normalizeTags(copies.flatMap(::categoryValues))
|
||||
val claimable = copies.singleOrNull()?.takeIf {
|
||||
it.params.isEmpty() && values.isNotEmpty() && ICalValues.formatTextList(values) == it.value
|
||||
}
|
||||
return values to claimable
|
||||
}
|
||||
|
||||
private fun categoryValues(property: ICalProperty): List<String> =
|
||||
normalizeTags(ICalValues.parseTextList(property.value))
|
||||
|
||||
private val CLASS_NAMES = listOf("PUBLIC", "PRIVATE", "CONFIDENTIAL")
|
||||
|
||||
private class TimeRead(
|
||||
|
||||
@@ -15,6 +15,7 @@ import de.jeanlucmakiola.agendula.data.tasks.room.TaskEntity
|
||||
import de.jeanlucmakiola.agendula.data.tasks.room.TaskListEntity
|
||||
import de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase
|
||||
import de.jeanlucmakiola.agendula.domain.PRIORITY_NONE
|
||||
import de.jeanlucmakiola.agendula.domain.normalizeTags
|
||||
import de.jeanlucmakiola.agendula.domain.statusFromInt
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
@@ -221,12 +222,17 @@ class OneShotImport @Inject constructor(
|
||||
}
|
||||
|
||||
val alarms = mutableListOf<LegacyAlarm>()
|
||||
db.rawQuery("SELECT task_id, mimetype, data0, data1, data2 FROM Properties", null)
|
||||
val categories = mutableMapOf<Long, MutableList<String>>()
|
||||
db.rawQuery("SELECT task_id, mimetype, data0, data1, data2 FROM Properties ORDER BY property_id", null)
|
||||
.use { cursor ->
|
||||
val r = CursorColumnReader(cursor)
|
||||
while (cursor.moveToNext()) {
|
||||
if (r.getString("mimetype") != ALARM_MIMETYPE) continue
|
||||
val taskId = r.getLong("task_id") ?: continue
|
||||
if (r.getString("mimetype") == CATEGORY_MIMETYPE) {
|
||||
r.getString("data1")?.let { categories.getOrPut(taskId, ::mutableListOf) += it }
|
||||
continue
|
||||
}
|
||||
if (r.getString("mimetype") != ALARM_MIMETYPE) continue
|
||||
val minutes = r.getString("data0")?.trim()?.toIntOrNull() ?: continue
|
||||
alarms += LegacyAlarm(
|
||||
taskId = taskId,
|
||||
@@ -241,7 +247,7 @@ class OneShotImport @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
return LegacySnapshot(lists, rows, alarms)
|
||||
return LegacySnapshot(lists, rows, alarms, categories.mapValues { normalizeTags(it.value) })
|
||||
}
|
||||
|
||||
// --- writing into Room ----------------------------------------------------
|
||||
@@ -276,7 +282,12 @@ class OneShotImport @Inject constructor(
|
||||
// the unique index; a fresh uid costs the row nothing it still has.
|
||||
val uid = row.entity.uid.takeIf { seen.add(Triple(listId, it, recurrenceId)) }
|
||||
?: UUID.randomUUID().toString()
|
||||
val entity = row.entity.copy(listId = listId, uid = uid, recurrenceId = recurrenceId)
|
||||
val entity = row.entity.copy(
|
||||
listId = listId,
|
||||
uid = uid,
|
||||
recurrenceId = recurrenceId,
|
||||
categories = snapshot.categories[row.id].orEmpty(),
|
||||
)
|
||||
val newId = taskDao.insert(entity)
|
||||
taskIds[row.id] = newId
|
||||
inserted += row to entity.copy(id = newId)
|
||||
@@ -379,6 +390,7 @@ class OneShotImport @Inject constructor(
|
||||
const val ARCHIVE_NAME = "tasks.db.imported"
|
||||
|
||||
private const val ALARM_MIMETYPE = "vnd.android.cursor.item/alarm"
|
||||
private const val CATEGORY_MIMETYPE = "vnd.android.cursor.item/category"
|
||||
private const val REFERENCE_START = "2"
|
||||
private val SIDECARS = listOf("-journal", "-wal", "-shm")
|
||||
private val IMPORT_DONE = booleanPreferencesKey("legacy_import_done")
|
||||
@@ -391,6 +403,8 @@ private class LegacySnapshot(
|
||||
val lists: List<LegacyList>,
|
||||
val tasks: List<LegacyTaskRow>,
|
||||
val alarms: List<LegacyAlarm>,
|
||||
/** Tags by legacy task `_id`. */
|
||||
val categories: Map<Long, List<String>>,
|
||||
)
|
||||
|
||||
private class LegacyList(val id: Long, val entity: TaskListEntity)
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
package de.jeanlucmakiola.agendula.data.tasks.room
|
||||
|
||||
import de.jeanlucmakiola.agendula.domain.ical.ICalComponent
|
||||
import de.jeanlucmakiola.agendula.domain.ical.ICalParser
|
||||
import de.jeanlucmakiola.agendula.domain.ical.ICalProperty
|
||||
import de.jeanlucmakiola.agendula.domain.ical.ICalSerializer
|
||||
import de.jeanlucmakiola.agendula.domain.ical.ICalValues
|
||||
|
||||
/**
|
||||
* Lifts `CATEGORIES` out of a residue written before v2 claimed it.
|
||||
*
|
||||
* ⚠️ Frozen with [TasksDatabase.MIGRATION_1_2]. The claim rule is copied here
|
||||
* rather than shared with the mapper, so a later change to what the mapper
|
||||
* claims cannot change what this migration produced for an earlier upgrader.
|
||||
*/
|
||||
internal object CategoriesLift {
|
||||
|
||||
/**
|
||||
* Every copy's values merged, and the residue with the one claimed line
|
||||
* removed — or `null` for the residue when nothing was claimed, so the caller
|
||||
* leaves the stored one alone.
|
||||
*/
|
||||
data class Result(val categories: List<String>, val residue: String?, val claimed: Boolean)
|
||||
|
||||
fun lift(residue: String?): Result {
|
||||
val component = parse(residue) ?: return Result(emptyList(), residue, claimed = false)
|
||||
val copies = component.properties.filter { it.name.equals("CATEGORIES", ignoreCase = true) }
|
||||
val values = copies.flatMap(::values)
|
||||
val claim = copies.singleOrNull()?.takeIf {
|
||||
it.params.isEmpty() && values.isNotEmpty() && ICalValues.formatTextList(values) == it.value
|
||||
} ?: return Result(values, residue, claimed = false)
|
||||
val rest = ICalSerializer.serializeProperties(component.properties.filterNot { it === claim }) +
|
||||
ICalSerializer.serializeAll(component.components)
|
||||
return Result(values, rest.ifEmpty { null }, claimed = true)
|
||||
}
|
||||
|
||||
private fun values(property: ICalProperty): List<String> =
|
||||
ICalValues.parseTextList(property.value).map(String::trim).filter(String::isNotEmpty).distinct()
|
||||
|
||||
private fun parse(text: String?): ICalComponent? {
|
||||
if (text.isNullOrEmpty()) return null
|
||||
return runCatching { ICalParser.parse("BEGIN:VTODO\r\n$text\r\nEND:VTODO\r\n") }.getOrNull()
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,8 @@ package de.jeanlucmakiola.agendula.data.tasks.room
|
||||
|
||||
import androidx.room.TypeConverter
|
||||
import de.jeanlucmakiola.agendula.domain.TaskStatus
|
||||
import de.jeanlucmakiola.agendula.domain.ical.ICalValues
|
||||
import de.jeanlucmakiola.agendula.domain.normalizeTags
|
||||
import de.jeanlucmakiola.agendula.domain.statusFromInt
|
||||
import de.jeanlucmakiola.agendula.domain.toInt
|
||||
import kotlin.time.Instant
|
||||
@@ -36,3 +38,17 @@ object Converters {
|
||||
fun alarmReferenceFrom(value: String): AlarmReference =
|
||||
runCatching { AlarmReference.valueOf(value) }.getOrDefault(AlarmReference.DUE)
|
||||
}
|
||||
|
||||
/**
|
||||
* [TaskEntity.categories] stored as a `CATEGORIES` value, so escaping has one
|
||||
* home; empty when there are none. Scoped to that field rather than the
|
||||
* database, so no other `List<String>` picks up iCalendar escaping by accident.
|
||||
*/
|
||||
object CategoriesConverter {
|
||||
@TypeConverter
|
||||
fun toText(value: List<String>): String = ICalValues.formatTextList(normalizeTags(value))
|
||||
|
||||
@TypeConverter
|
||||
fun fromText(value: String): List<String> =
|
||||
if (value.isEmpty()) emptyList() else normalizeTags(ICalValues.parseTextList(value))
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import androidx.room.Entity
|
||||
import androidx.room.ForeignKey
|
||||
import androidx.room.Index
|
||||
import androidx.room.PrimaryKey
|
||||
import androidx.room.TypeConverters
|
||||
import de.jeanlucmakiola.agendula.domain.PRIORITY_NONE
|
||||
import de.jeanlucmakiola.agendula.domain.TaskStatus
|
||||
import kotlin.time.Instant
|
||||
@@ -71,7 +72,7 @@ data class TaskListEntity(
|
||||
*
|
||||
* ⚠️ It was set by a rename and read by nobody. A collection's name and
|
||||
* colour are now written server-first by
|
||||
* [de.jeanlucmakiola.agendula.data.sync.RemoteListRepository], so there is
|
||||
* [de.jeanlucmakiola.agendula.data.sync.RemoteLists], so there is
|
||||
* no local edit left waiting to be pushed — and a flag that means "owed to
|
||||
* the server" while nothing ever pays it is worse than no flag at all.
|
||||
*/
|
||||
@@ -143,6 +144,10 @@ data class TaskEntity(
|
||||
@ColumnInfo(name = "url") val url: String? = null,
|
||||
/** ARGB override for the list colour. */
|
||||
@ColumnInfo(name = "color") val color: Int? = null,
|
||||
/** RFC 5545 `CATEGORIES` — the tags — in the spelling each task carries them. */
|
||||
@ColumnInfo(name = "categories", defaultValue = "")
|
||||
@field:TypeConverters(CategoriesConverter::class)
|
||||
val categories: List<String> = emptyList(),
|
||||
|
||||
// state
|
||||
@ColumnInfo(name = "status", defaultValue = "0") val status: TaskStatus = TaskStatus.NEEDS_ACTION,
|
||||
|
||||
@@ -49,6 +49,7 @@ object RoomTaskMapper {
|
||||
description = row.task.description,
|
||||
location = row.task.location,
|
||||
url = row.task.url,
|
||||
tags = row.task.categories,
|
||||
priority = priorityFromICal(row.task.priority),
|
||||
status = row.task.status,
|
||||
percentComplete = row.task.percentComplete,
|
||||
@@ -79,6 +80,7 @@ object RoomTaskMapper {
|
||||
description = task.description,
|
||||
location = task.location,
|
||||
url = task.url,
|
||||
tags = task.categories,
|
||||
priority = priorityFromICal(task.priority),
|
||||
status = task.status,
|
||||
percentComplete = task.percentComplete,
|
||||
|
||||
@@ -520,7 +520,7 @@ class RoomTasksDataSource @Inject constructor(
|
||||
|
||||
/**
|
||||
* ⚠️ Device-only lists. An account-backed collection is renamed through
|
||||
* [de.jeanlucmakiola.agendula.data.sync.RemoteListRepository], which sends
|
||||
* [de.jeanlucmakiola.agendula.data.sync.RemoteLists], which sends
|
||||
* the PROPPATCH first and writes here only once the server has taken it.
|
||||
*
|
||||
* This used to set `is_dirty` for an account list instead, on the theory
|
||||
|
||||
@@ -3,6 +3,7 @@ package de.jeanlucmakiola.agendula.data.tasks.room
|
||||
import de.jeanlucmakiola.agendula.domain.TaskForm
|
||||
import de.jeanlucmakiola.agendula.domain.TaskStatus
|
||||
import de.jeanlucmakiola.agendula.domain.ical.ICalValues
|
||||
import de.jeanlucmakiola.agendula.domain.normalizeTags
|
||||
import de.jeanlucmakiola.agendula.domain.toICal
|
||||
import kotlin.time.Duration.Companion.days
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
@@ -50,6 +51,7 @@ object TaskFormWriter {
|
||||
description = form.description?.trim()?.ifBlank { null },
|
||||
location = form.location?.trim()?.ifBlank { null },
|
||||
url = form.url?.trim()?.ifBlank { null },
|
||||
categories = normalizeTags(form.tags),
|
||||
priority = form.priority.toICal(),
|
||||
percentComplete = percent,
|
||||
status = statusFor(percent, current.status),
|
||||
|
||||
@@ -3,6 +3,8 @@ package de.jeanlucmakiola.agendula.data.tasks.room
|
||||
import androidx.room.Database
|
||||
import androidx.room.RoomDatabase
|
||||
import androidx.room.TypeConverters
|
||||
import androidx.room.migration.Migration
|
||||
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||
|
||||
/**
|
||||
* Agendula's own task store. Four tables, designed from
|
||||
@@ -18,7 +20,7 @@ import androidx.room.TypeConverters
|
||||
TaskEntity::class,
|
||||
TaskAlarmEntity::class,
|
||||
],
|
||||
version = 1,
|
||||
version = 2,
|
||||
exportSchema = true,
|
||||
)
|
||||
@TypeConverters(Converters::class)
|
||||
@@ -30,5 +32,36 @@ abstract class TasksDatabase : RoomDatabase() {
|
||||
|
||||
companion object {
|
||||
const val NAME = "agendula-tasks.db"
|
||||
|
||||
/**
|
||||
* v2 adds `categories`. Until then `CATEGORIES` lived in the residue, and
|
||||
* an unchanged task is never downloaded again, so the values are lifted
|
||||
* out here. Not marked dirty: nothing changed on the server's side.
|
||||
*/
|
||||
val MIGRATION_1_2 = object : Migration(1, 2) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
db.execSQL("ALTER TABLE tasks ADD COLUMN categories TEXT NOT NULL DEFAULT ''")
|
||||
val lifted = mutableListOf<Pair<Long, CategoriesLift.Result>>()
|
||||
db.query(
|
||||
"SELECT id, unknown_properties FROM tasks WHERE unknown_properties LIKE '%CATEGORIES%'",
|
||||
).use { cursor ->
|
||||
while (cursor.moveToNext()) {
|
||||
val result = CategoriesLift.lift(cursor.getString(1))
|
||||
if (result.categories.isNotEmpty()) lifted += cursor.getLong(0) to result
|
||||
}
|
||||
}
|
||||
lifted.forEach { (id, result) ->
|
||||
val categories = CategoriesConverter.toText(result.categories)
|
||||
if (result.claimed) {
|
||||
db.execSQL(
|
||||
"UPDATE tasks SET categories = ?, unknown_properties = ? WHERE id = ?",
|
||||
arrayOf<Any?>(categories, result.residue, id),
|
||||
)
|
||||
} else {
|
||||
db.execSQL("UPDATE tasks SET categories = ? WHERE id = ?", arrayOf<Any?>(categories, id))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import de.jeanlucmakiola.agendula.data.tasks.room.TaskListEntity
|
||||
import de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase
|
||||
import de.jeanlucmakiola.agendula.domain.TaskList
|
||||
import de.jeanlucmakiola.agendula.domain.export.ExportTask
|
||||
import de.jeanlucmakiola.agendula.domain.normalizeTags
|
||||
import de.jeanlucmakiola.agendula.domain.toICal
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
@@ -213,6 +214,7 @@ class ExternalImport @Inject constructor(
|
||||
description = description,
|
||||
location = location,
|
||||
url = url,
|
||||
categories = normalizeTags(tags),
|
||||
status = status,
|
||||
percentComplete = percentComplete,
|
||||
completedAt = completedAt,
|
||||
|
||||
@@ -52,6 +52,8 @@ data class Task(
|
||||
val description: String?,
|
||||
val location: String?,
|
||||
val url: String?,
|
||||
/** `CATEGORIES`, as the task spells them. */
|
||||
val tags: List<String> = emptyList(),
|
||||
val priority: Priority,
|
||||
val status: TaskStatus,
|
||||
val percentComplete: Int?,
|
||||
@@ -161,3 +163,10 @@ fun TaskStatus.toInt(): Int = when (this) {
|
||||
TaskStatus.COMPLETED -> ICalStatus.COMPLETED
|
||||
TaskStatus.CANCELLED -> ICalStatus.CANCELLED
|
||||
}
|
||||
|
||||
/** Tags as every writer stores them: trimmed, no blanks, each spelling once, order kept. */
|
||||
fun normalizeTags(tags: List<String>): List<String> = tags.map(String::trim).filter(String::isNotEmpty).distinct()
|
||||
|
||||
/** Every tag on [tasks], each spelling once, alphabetically ignoring case. */
|
||||
fun tagsInUse(tasks: Iterable<Task>): List<String> =
|
||||
tasks.flatMap { it.tags }.distinct().sortedWith(String.CASE_INSENSITIVE_ORDER)
|
||||
@@ -0,0 +1,96 @@
|
||||
package de.jeanlucmakiola.agendula.domain
|
||||
|
||||
import java.time.LocalDate
|
||||
import java.time.ZoneId
|
||||
import kotlin.time.Instant
|
||||
|
||||
/** Where an open task's due date falls, relative to today. */
|
||||
enum class DueWindow { OVERDUE, TODAY, UPCOMING, NO_DATE }
|
||||
|
||||
/** How a [SavedFilter] with several tags reads them. */
|
||||
enum class TagMatch { ANY, ALL }
|
||||
|
||||
/**
|
||||
* A user-made view over the open tasks. An empty criterion doesn't narrow:
|
||||
* no [due] windows means any due date, no [listIds] any list, no [tags] any tag.
|
||||
* [minPriority] [Priority.NONE] lets every priority through.
|
||||
*
|
||||
* [listsElsewhere] is set when the filter also has lists in another task store.
|
||||
* With none of its lists here it then matches nothing rather than every list.
|
||||
*/
|
||||
data class SavedFilter(
|
||||
val id: Long,
|
||||
val name: String,
|
||||
val due: Set<DueWindow> = emptySet(),
|
||||
val listIds: Set<Long> = emptySet(),
|
||||
val tags: Set<String> = emptySet(),
|
||||
val tagMatch: TagMatch = TagMatch.ANY,
|
||||
val minPriority: Priority = Priority.NONE,
|
||||
val listsElsewhere: Boolean = false,
|
||||
) {
|
||||
/** Whether the filter narrows by list at all. */
|
||||
val anyList: Boolean get() = listIds.isEmpty() && !listsElsewhere
|
||||
|
||||
/** This filter without the list ids missing from [existing], e.g. lists since deleted. */
|
||||
fun withListsIn(existing: Set<Long>): SavedFilter = copy(listIds = listIds intersect existing)
|
||||
|
||||
fun matches(task: Task, todayStart: Instant, todayEnd: Instant): Boolean {
|
||||
if (task.isClosed) return false
|
||||
if (listsElsewhere && listIds.isEmpty()) return false
|
||||
if (listIds.isNotEmpty() && task.listId !in listIds) return false
|
||||
if (task.priority < minPriority) return false
|
||||
if (due.isNotEmpty() && dueWindowOf(task, todayStart, todayEnd) !in due) return false
|
||||
return when {
|
||||
tags.isEmpty() -> true
|
||||
tagMatch == TagMatch.ALL -> task.tags.containsAll(tags)
|
||||
else -> task.tags.any { it in tags }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Where [task]'s due falls against local today ([todayStart] to [todayEnd], from
|
||||
* [DayWindow] in [zone]). An all-day due is stored as UTC midnight, so it is read
|
||||
* as its calendar date first rather than compared as a raw instant.
|
||||
*/
|
||||
fun dueWindowOf(
|
||||
task: Task,
|
||||
todayStart: Instant,
|
||||
todayEnd: Instant,
|
||||
zone: ZoneId = ZoneId.systemDefault(),
|
||||
): DueWindow {
|
||||
val due = task.due?.let { if (task.isAllDay) it.rebasedForAllDay(allDay = false, zone = zone) else it }
|
||||
return when {
|
||||
due == null -> DueWindow.NO_DATE
|
||||
due < todayStart -> DueWindow.OVERDUE
|
||||
due < todayEnd -> DueWindow.TODAY
|
||||
else -> DueWindow.UPCOMING
|
||||
}
|
||||
}
|
||||
|
||||
/** What a task created from a [SavedFilter] starts with, so it lands in that filter. */
|
||||
data class NewTaskPreset(
|
||||
val listIds: Set<Long> = emptySet(),
|
||||
val tags: List<String> = emptyList(),
|
||||
val dueDate: LocalDate? = null,
|
||||
val priority: Priority = Priority.NONE,
|
||||
)
|
||||
|
||||
/**
|
||||
* The preset for a new task in this filter. Due prefers today, then tomorrow;
|
||||
* a filter that admits undated tasks, or only overdue ones, leaves it empty.
|
||||
*/
|
||||
fun SavedFilter.newTaskPreset(today: LocalDate): NewTaskPreset = NewTaskPreset(
|
||||
listIds = listIds,
|
||||
tags = when {
|
||||
tagMatch == TagMatch.ALL -> tags.sorted()
|
||||
else -> listOfNotNull(tags.minOrNull())
|
||||
},
|
||||
dueDate = when {
|
||||
due.isEmpty() || DueWindow.NO_DATE in due -> null
|
||||
DueWindow.TODAY in due -> today
|
||||
DueWindow.UPCOMING in due -> today.plusDays(1)
|
||||
else -> null
|
||||
},
|
||||
priority = minPriority,
|
||||
)
|
||||
@@ -5,10 +5,16 @@ import kotlin.time.Instant
|
||||
/** The built-in "smart" lists, computed from due dates rather than membership. */
|
||||
enum class SmartList { ALL, TODAY, UPCOMING, OVERDUE, NO_DATE, COMPLETED }
|
||||
|
||||
/** What a task screen is showing: one real list, or a smart list. */
|
||||
/** What a task screen is showing: one real list, a smart list, or a tag. */
|
||||
sealed interface TaskFilter {
|
||||
data class OfList(val listId: Long) : TaskFilter
|
||||
/** Several lists shown together, e.g. one widget covering more than one list. */
|
||||
data class OfLists(val listIds: Set<Long>) : TaskFilter
|
||||
data class Smart(val list: SmartList) : TaskFilter
|
||||
/** Every task carrying [tag], open and completed, across all lists. */
|
||||
data class Tagged(val tag: String) : TaskFilter
|
||||
/** A [SavedFilter] by id; the repository resolves it to its definition. */
|
||||
data class Saved(val id: Long) : TaskFilter
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -18,21 +24,32 @@ sealed interface TaskFilter {
|
||||
*/
|
||||
object TaskFiltering {
|
||||
|
||||
fun matches(task: Task, filter: TaskFilter, todayStart: Instant, todayEnd: Instant): Boolean =
|
||||
/** [saved] is the definition behind a [TaskFilter.Saved]; without one it matches nothing. */
|
||||
fun matches(
|
||||
task: Task,
|
||||
filter: TaskFilter,
|
||||
todayStart: Instant,
|
||||
todayEnd: Instant,
|
||||
saved: SavedFilter? = null,
|
||||
): Boolean =
|
||||
when (filter) {
|
||||
is TaskFilter.OfList -> task.listId == filter.listId
|
||||
is TaskFilter.OfLists -> task.listId in filter.listIds
|
||||
is TaskFilter.Smart -> matchesSmart(task, filter.list, todayStart, todayEnd)
|
||||
is TaskFilter.Tagged -> filter.tag in task.tags
|
||||
is TaskFilter.Saved -> saved?.takeIf { it.id == filter.id }?.matches(task, todayStart, todayEnd) ?: false
|
||||
}
|
||||
|
||||
private fun matchesSmart(task: Task, list: SmartList, todayStart: Instant, todayEnd: Instant): Boolean {
|
||||
val due = task.due
|
||||
return when (list) {
|
||||
/** Whether [task] carries every one of [tags]; an empty set matches everything. */
|
||||
fun hasAllTags(task: Task, tags: Set<String>): Boolean = task.tags.containsAll(tags)
|
||||
|
||||
private fun matchesSmart(task: Task, list: SmartList, todayStart: Instant, todayEnd: Instant): Boolean =
|
||||
when (list) {
|
||||
SmartList.COMPLETED -> task.isCompleted
|
||||
SmartList.ALL -> !task.isClosed
|
||||
SmartList.NO_DATE -> !task.isClosed && due == null
|
||||
SmartList.OVERDUE -> !task.isClosed && due != null && due < todayStart
|
||||
SmartList.TODAY -> !task.isClosed && due != null && due >= todayStart && due < todayEnd
|
||||
SmartList.UPCOMING -> !task.isClosed && due != null && due >= todayEnd
|
||||
SmartList.NO_DATE -> !task.isClosed && dueWindowOf(task, todayStart, todayEnd) == DueWindow.NO_DATE
|
||||
SmartList.OVERDUE -> !task.isClosed && dueWindowOf(task, todayStart, todayEnd) == DueWindow.OVERDUE
|
||||
SmartList.TODAY -> !task.isClosed && dueWindowOf(task, todayStart, todayEnd) == DueWindow.TODAY
|
||||
SmartList.UPCOMING -> !task.isClosed && dueWindowOf(task, todayStart, todayEnd) == DueWindow.UPCOMING
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,7 @@ data class TaskForm(
|
||||
val reminders: List<Int> = emptyList(),
|
||||
val location: String? = null,
|
||||
val url: String? = null,
|
||||
val tags: List<String> = emptyList(),
|
||||
/** The series' `RRULE` value; `null` = does not repeat. Ignored for a single-occurrence edit. */
|
||||
val rrule: String? = null,
|
||||
) {
|
||||
@@ -44,7 +45,7 @@ enum class TaskFormError { BLANK_TITLE, NO_LIST, DUE_BEFORE_START, REMINDER_WITH
|
||||
* a setting; the rest unfold on demand. Declaring these as an enum keeps the
|
||||
* disclosure generic, so adding a field later is one entry plus its card.
|
||||
*/
|
||||
enum class TaskFormField { Description, Recurrence, Priority, Progress, Parent, Reminder, Location, Url }
|
||||
enum class TaskFormField { Description, Recurrence, Priority, Progress, Parent, Reminder, Location, Url, Tags }
|
||||
|
||||
/** The optional fields that already carry a value — auto-revealed when editing. */
|
||||
fun TaskForm.populatedFields(): Set<TaskFormField> = buildSet {
|
||||
@@ -56,4 +57,5 @@ fun TaskForm.populatedFields(): Set<TaskFormField> = buildSet {
|
||||
if (rrule != null) add(TaskFormField.Recurrence)
|
||||
if (!location.isNullOrBlank()) add(TaskFormField.Location)
|
||||
if (!url.isNullOrBlank()) add(TaskFormField.Url)
|
||||
if (tags.isNotEmpty()) add(TaskFormField.Tags)
|
||||
}
|
||||
@@ -29,11 +29,11 @@ object TaskSections {
|
||||
|
||||
fun sectionOf(task: Task, todayStart: Instant, todayEnd: Instant): TaskSection {
|
||||
if (task.isCompleted) return TaskSection.COMPLETED
|
||||
val due = task.due ?: return TaskSection.NO_DATE
|
||||
return when {
|
||||
due < todayStart -> TaskSection.OVERDUE
|
||||
due < todayEnd -> TaskSection.TODAY
|
||||
else -> TaskSection.UPCOMING
|
||||
return when (dueWindowOf(task, todayStart, todayEnd)) {
|
||||
DueWindow.OVERDUE -> TaskSection.OVERDUE
|
||||
DueWindow.TODAY -> TaskSection.TODAY
|
||||
DueWindow.UPCOMING -> TaskSection.UPCOMING
|
||||
DueWindow.NO_DATE -> TaskSection.NO_DATE
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -42,6 +42,7 @@ data class ExportTask(
|
||||
val rdate: String?,
|
||||
/** `tasks._id` of the parent, for `RELATED-TO;RELTYPE=PARENT`. */
|
||||
val parentId: Long?,
|
||||
val tags: List<String> = emptyList(),
|
||||
)
|
||||
|
||||
/** A task list and everything in it, ready to become one `.ics` document. */
|
||||
|
||||
@@ -88,6 +88,7 @@ object ICalendarWriter {
|
||||
task.location?.takeIf { it.isNotBlank() }?.let { property("LOCATION", it) }
|
||||
// URL is a URI, not TEXT: it must not be escaped like one.
|
||||
task.url?.takeIf { it.isNotBlank() }?.let { rawProperty("URL", it) }
|
||||
task.tags.takeIf { it.isNotEmpty() }?.let { rawProperty("CATEGORIES", ICalValues.formatTextList(it)) }
|
||||
|
||||
task.start?.let { dateProperty("DTSTART", it, task.isAllDay) }
|
||||
task.due?.let { dateProperty("DUE", it, task.isAllDay) }
|
||||
|
||||
@@ -49,6 +49,37 @@ object ICalValues {
|
||||
return out.toString()
|
||||
}
|
||||
|
||||
/**
|
||||
* A multi-valued TEXT property (`CATEGORIES`, §3.8.1.2): split on the
|
||||
* commas that are not escaped, then unescape each value.
|
||||
*/
|
||||
fun parseTextList(value: String): List<String> {
|
||||
val values = mutableListOf<String>()
|
||||
val current = StringBuilder()
|
||||
var i = 0
|
||||
while (i < value.length) {
|
||||
val c = value[i]
|
||||
when {
|
||||
c == '\\' && i + 1 < value.length -> {
|
||||
current.append(c).append(value[i + 1])
|
||||
i += 2
|
||||
continue
|
||||
}
|
||||
c == ',' -> {
|
||||
values += unescapeText(current.toString())
|
||||
current.clear()
|
||||
}
|
||||
else -> current.append(c)
|
||||
}
|
||||
i++
|
||||
}
|
||||
values += unescapeText(current.toString())
|
||||
return values
|
||||
}
|
||||
|
||||
/** The inverse of [parseTextList]. */
|
||||
fun formatTextList(values: List<String>): String = values.joinToString(",", transform = ::escapeText)
|
||||
|
||||
/** How a DATE / DATE-TIME property was written, and whether we can reproduce it. */
|
||||
sealed interface TimeValue {
|
||||
/** `VALUE=DATE` — date-only. */
|
||||
|
||||
@@ -1,481 +0,0 @@
|
||||
package de.jeanlucmakiola.agendula.ui.accounts
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.rounded.List
|
||||
import androidx.compose.material.icons.automirrored.rounded.Login
|
||||
import androidx.compose.material.icons.rounded.Check
|
||||
import androidx.compose.material.icons.rounded.Bolt
|
||||
import androidx.compose.material.icons.rounded.CloudSync
|
||||
import androidx.compose.material.icons.rounded.DeleteOutline
|
||||
import androidx.compose.material.icons.rounded.History
|
||||
import androidx.compose.material.icons.rounded.HistoryToggleOff
|
||||
import androidx.compose.material.icons.rounded.Inventory2
|
||||
import androidx.compose.material.icons.rounded.Refresh
|
||||
import androidx.compose.material.icons.rounded.SyncProblem
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.withStyle
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import de.jeanlucmakiola.agendula.R
|
||||
import de.jeanlucmakiola.agendula.data.sync.DiscardedEdit
|
||||
import de.jeanlucmakiola.agendula.data.sync.SyncNotice
|
||||
import de.jeanlucmakiola.agendula.data.tasks.room.AccountEntity
|
||||
import de.jeanlucmakiola.agendula.ui.accounts.add.message
|
||||
import de.jeanlucmakiola.agendula.ui.common.OnResume
|
||||
import de.jeanlucmakiola.floret.components.CollapsingScaffold
|
||||
import de.jeanlucmakiola.floret.components.FullScreenPicker
|
||||
import de.jeanlucmakiola.floret.components.GroupedListInset
|
||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||
import de.jeanlucmakiola.floret.components.Position
|
||||
import de.jeanlucmakiola.floret.components.SelectedCheck
|
||||
import de.jeanlucmakiola.floret.components.positionOf
|
||||
|
||||
/**
|
||||
* One account: who it is, how its last sync went, and what can be done to it.
|
||||
*
|
||||
* Tapping a row in the list opens this rather than a destructive prompt — a list
|
||||
* row leads somewhere, it does not fire an irreversible action — so removing an
|
||||
* account is a button on the account's own screen.
|
||||
*/
|
||||
@Composable
|
||||
internal fun AccountDetailScreen(
|
||||
accountId: Long,
|
||||
onBack: () -> Unit,
|
||||
onRemoved: () -> Unit,
|
||||
onSignInAgain: (AccountEntity) -> Unit,
|
||||
onOpenStorage: () -> Unit,
|
||||
viewModel: AccountsViewModel,
|
||||
) {
|
||||
val accounts by viewModel.accounts.collectAsStateWithLifecycle()
|
||||
val usable by viewModel.accountsUsable.collectAsStateWithLifecycle()
|
||||
val push by viewModel.push.collectAsStateWithLifecycle()
|
||||
OnResume(viewModel::refreshPush)
|
||||
val row = accounts?.firstOrNull { it.account.id == accountId }
|
||||
|
||||
// The row is gone the instant it is removed, while this screen is still
|
||||
// sliding away. Keeping the last one it had stops that exit animating an
|
||||
// empty screen.
|
||||
var lastKnown by remember(accountId) { mutableStateOf(row) }
|
||||
LaunchedEffect(row) { if (row != null) lastKnown = row }
|
||||
val shown = row ?: lastKnown ?: return
|
||||
|
||||
val account = shown.account
|
||||
val identity = account.identity()
|
||||
// The confirmation sheet. The removal it starts outlives this screen, and is
|
||||
// shown on the accounts list rather than here.
|
||||
var confirming by remember { mutableStateOf(false) }
|
||||
|
||||
CollapsingScaffold(title = identity.title, onBack = onBack) {
|
||||
AccountHero(identity)
|
||||
Spacer(Modifier.height(24.dp))
|
||||
|
||||
val actions = buildList<@Composable (Position) -> Unit> {
|
||||
add { position ->
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.accounts_last_sync),
|
||||
summary = syncState(shown),
|
||||
position = position,
|
||||
leading = { Icon(Icons.Rounded.History, contentDescription = null) },
|
||||
)
|
||||
}
|
||||
if (!usable) return@buildList
|
||||
push?.let { pushState ->
|
||||
add { position ->
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.accounts_push),
|
||||
summary = accountPushSummary(pushState, shown.lists),
|
||||
position = position,
|
||||
leading = { Icon(Icons.Rounded.Bolt, contentDescription = null) },
|
||||
)
|
||||
}
|
||||
}
|
||||
if (shown.needsSignIn) {
|
||||
add { position ->
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.accounts_sign_in_again),
|
||||
position = position,
|
||||
leading = { Icon(Icons.AutoMirrored.Rounded.Login, contentDescription = null) },
|
||||
onClick = { onSignInAgain(account) },
|
||||
)
|
||||
}
|
||||
} else {
|
||||
add { position ->
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.accounts_sync_now),
|
||||
position = position,
|
||||
leading = { Icon(Icons.Rounded.CloudSync, contentDescription = null) },
|
||||
onClick = { viewModel.syncNow(account) },
|
||||
)
|
||||
}
|
||||
add { position ->
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.accounts_lists),
|
||||
summary = stringResource(R.string.accounts_lists_summary),
|
||||
position = position,
|
||||
leading = { Icon(Icons.AutoMirrored.Rounded.List, contentDescription = null) },
|
||||
onClick = { viewModel.openLists(account.id) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
actions.forEachIndexed { index, action -> action(positionOf(index, actions.size)) }
|
||||
|
||||
if (!usable) {
|
||||
Spacer(Modifier.height(24.dp))
|
||||
ExternalStorageNotice(onOpenStorage)
|
||||
}
|
||||
|
||||
// ⚠️ Above the removal, below the state — because it is the one thing on
|
||||
// this screen the user did not already know. Conflicts are server-wins,
|
||||
// and this group is the other half of it:
|
||||
// without it a discarded edit is indistinguishable from lost work.
|
||||
if (shown.notices.isNotEmpty()) {
|
||||
Spacer(Modifier.height(24.dp))
|
||||
SyncNotices(
|
||||
notices = shown.notices,
|
||||
onDismiss = { viewModel.dismissNotices(account) },
|
||||
onRetry = viewModel::retry,
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(24.dp))
|
||||
|
||||
// Its own group, away from the things that are safe to press twice.
|
||||
//
|
||||
// ⚠️ Nothing here shows the removal in flight, deliberately. Confirming
|
||||
// hands off to `onRemoved`, which puts this screen away in the same
|
||||
// frame — so a pending state drawn here could never appear, and the
|
||||
// dimming and spinner that were written for it were dead code claiming
|
||||
// to prevent a second tap that cannot happen. The wait is visible where
|
||||
// the user actually ends up: the row on `AccountsScreen`.
|
||||
GroupedRow(
|
||||
title = errorTitle(stringResource(R.string.accounts_remove)),
|
||||
position = Position.Alone,
|
||||
leading = {
|
||||
Icon(
|
||||
Icons.Rounded.DeleteOutline,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
},
|
||||
onClick = { confirming = true },
|
||||
)
|
||||
Spacer(Modifier.height(24.dp))
|
||||
}
|
||||
|
||||
val lists by viewModel.lists.collectAsStateWithLifecycle()
|
||||
lists?.let { state ->
|
||||
SyncedListsPicker(state = state, viewModel = viewModel)
|
||||
}
|
||||
|
||||
if (confirming) {
|
||||
RemoveAccountPicker(
|
||||
identity = identity,
|
||||
onDismiss = { confirming = false },
|
||||
onRemove = { deleteLocalData ->
|
||||
viewModel.remove(account, deleteLocalData)
|
||||
confirming = false
|
||||
onRemoved()
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* What the last syncs replaced or gave up on, one row each.
|
||||
*
|
||||
* Named, never counted. "3 edits were replaced" leaves the user to work out
|
||||
* which three across every list they own, which is the same as not telling them
|
||||
* — so each row carries the task's own title, the list it lives in, and the
|
||||
* reason, and "Got it" is what clears them.
|
||||
*/
|
||||
@Composable
|
||||
private fun SyncNotices(
|
||||
notices: List<SyncNotice>,
|
||||
onDismiss: () -> Unit,
|
||||
onRetry: (SyncNotice) -> Unit,
|
||||
) {
|
||||
Text(
|
||||
stringResource(R.string.sync_notices_title),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = GroupedListInset, vertical = 8.dp),
|
||||
)
|
||||
// The dismissal is a row of the same group: it is the last thing you do to
|
||||
// this list, and a floating button beside it would read as belonging to the
|
||||
// screen rather than to these notices.
|
||||
val rows = notices.size + 1
|
||||
notices.forEachIndexed { index, notice ->
|
||||
GroupedRow(
|
||||
title = notice.subject.ifBlank { stringResource(R.string.task_untitled) },
|
||||
summary = stringResource(
|
||||
R.string.accounts_summary,
|
||||
notice.listName,
|
||||
stringResource(notice.cause.message),
|
||||
),
|
||||
position = positionOf(index, rows),
|
||||
leading = {
|
||||
Icon(
|
||||
if (notice.kind == SyncNotice.Kind.QUARANTINED) {
|
||||
Icons.Rounded.SyncProblem
|
||||
} else {
|
||||
Icons.Rounded.HistoryToggleOff
|
||||
},
|
||||
contentDescription = null,
|
||||
)
|
||||
},
|
||||
trailing = if (notice.kind == SyncNotice.Kind.QUARANTINED) {
|
||||
{
|
||||
IconButton(onClick = { onRetry(notice) }) {
|
||||
Icon(
|
||||
Icons.Rounded.Refresh,
|
||||
contentDescription = stringResource(R.string.sync_notice_retry),
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
null
|
||||
},
|
||||
)
|
||||
}
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.sync_notices_dismiss),
|
||||
position = positionOf(notices.size, rows),
|
||||
leading = { Icon(Icons.Rounded.Check, contentDescription = null) },
|
||||
onClick = onDismiss,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Why an edit is gone, in words rather than an enum name.
|
||||
*
|
||||
* A quarantine has no cause of its own — nothing chose it, a resource simply
|
||||
* kept failing — so the null branch is the sentence for that, not a fallback.
|
||||
*/
|
||||
private val DiscardedEdit.Cause?.message: Int
|
||||
get() = when (this) {
|
||||
DiscardedEdit.Cause.SERVER_NEWER -> R.string.sync_notice_cause_server_newer
|
||||
DiscardedEdit.Cause.DELETED_ON_SERVER -> R.string.sync_notice_cause_deleted_on_server
|
||||
DiscardedEdit.Cause.DELETE_LOST -> R.string.sync_notice_cause_delete_lost
|
||||
null -> R.string.sync_notice_cause_quarantined
|
||||
}
|
||||
|
||||
/** The account's logo at full size, over the two things the title bar left out. */
|
||||
@Composable
|
||||
private fun AccountHero(identity: AccountIdentity) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = GroupedListInset),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
ProviderLogo(identity.provider, size = 72.dp)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
identity.user?.let {
|
||||
Text(it, style = MaterialTheme.typography.titleMedium)
|
||||
}
|
||||
Text(
|
||||
identity.secondary ?: stringResource(R.string.accounts_generic_provider),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Removing an account is two different things — the lists stay behind as
|
||||
* device-only lists, or they go with it — so it is a browse-style choice, and
|
||||
* those are full-screen. Each row says what it does and does it; leaving without
|
||||
* choosing is back.
|
||||
*/
|
||||
@Composable
|
||||
private fun RemoveAccountPicker(
|
||||
identity: AccountIdentity,
|
||||
onDismiss: () -> Unit,
|
||||
onRemove: (deleteLocalData: Boolean) -> Unit,
|
||||
) {
|
||||
FullScreenPicker(
|
||||
title = stringResource(R.string.accounts_remove),
|
||||
onDismiss = onDismiss,
|
||||
predictiveBack = true,
|
||||
) {
|
||||
Text(
|
||||
stringResource(R.string.accounts_remove_body, identity.title),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = GroupedListInset),
|
||||
)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.accounts_remove_keep),
|
||||
summary = stringResource(R.string.accounts_remove_keep_body),
|
||||
position = Position.Top,
|
||||
leading = { Icon(Icons.Rounded.Inventory2, contentDescription = null) },
|
||||
onClick = { onRemove(false) },
|
||||
)
|
||||
GroupedRow(
|
||||
title = errorTitle(stringResource(R.string.accounts_remove_wipe)),
|
||||
summary = AnnotatedString(stringResource(R.string.accounts_remove_wipe_body)),
|
||||
position = Position.Bottom,
|
||||
leading = {
|
||||
Icon(
|
||||
Icons.Rounded.DeleteOutline,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
},
|
||||
onClick = { onRemove(true) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Which of the account's collections sync, re-read from the server — so lists
|
||||
* made there after setup can be added, and synced ones dropped.
|
||||
*/
|
||||
@Composable
|
||||
private fun SyncedListsPicker(state: AccountsViewModel.ListsState, viewModel: AccountsViewModel) {
|
||||
var confirmingDrop by remember { mutableStateOf(false) }
|
||||
val ready = state as? AccountsViewModel.ListsState.Ready
|
||||
|
||||
FullScreenPicker(
|
||||
title = stringResource(R.string.accounts_lists),
|
||||
onDismiss = viewModel::closeLists,
|
||||
predictiveBack = true,
|
||||
actions = {
|
||||
if (ready != null) {
|
||||
Button(
|
||||
onClick = {
|
||||
if (ready.dropping) confirmingDrop = true else viewModel.saveLists(keepUnticked = true)
|
||||
},
|
||||
modifier = Modifier.padding(end = 12.dp),
|
||||
) { Text(stringResource(R.string.save)) }
|
||||
}
|
||||
},
|
||||
) {
|
||||
when (state) {
|
||||
AccountsViewModel.ListsState.Loading -> Row(
|
||||
modifier = Modifier.padding(horizontal = GroupedListInset),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
CircularProgressIndicator(Modifier.size(20.dp))
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Text(
|
||||
stringResource(R.string.accounts_lists_loading),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
|
||||
AccountsViewModel.ListsState.NeedsSignIn -> PickerNote(stringResource(R.string.accounts_needs_sign_in))
|
||||
|
||||
is AccountsViewModel.ListsState.Failed -> PickerNote(
|
||||
stringResource(state.cause?.message ?: R.string.add_account_error_server),
|
||||
)
|
||||
|
||||
is AccountsViewModel.ListsState.Ready -> state.collections.forEachIndexed { index, collection ->
|
||||
val checked = collection.url in state.selected
|
||||
GroupedRow(
|
||||
title = collection.displayName ?: collection.url.encodedPath,
|
||||
summary = when {
|
||||
collection.readOnly -> stringResource(R.string.add_account_lists_read_only)
|
||||
collection.isShared -> stringResource(R.string.add_account_lists_shared)
|
||||
else -> null
|
||||
},
|
||||
position = positionOf(index, state.collections.size),
|
||||
selected = checked,
|
||||
trailing = { if (checked) SelectedCheck() },
|
||||
onClick = { viewModel.toggleList(collection.url) },
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(16.dp))
|
||||
}
|
||||
|
||||
if (confirmingDrop) {
|
||||
DropListsPicker(
|
||||
onDismiss = { confirmingDrop = false },
|
||||
onChoose = { keep ->
|
||||
confirmingDrop = false
|
||||
viewModel.saveLists(keepUnticked = keep)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PickerNote(text: String) {
|
||||
Text(
|
||||
text,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = GroupedListInset),
|
||||
)
|
||||
}
|
||||
|
||||
/** What happens to the tasks of lists that stop syncing — the same two ways removing an account offers. */
|
||||
@Composable
|
||||
private fun DropListsPicker(onDismiss: () -> Unit, onChoose: (keep: Boolean) -> Unit) {
|
||||
FullScreenPicker(
|
||||
title = stringResource(R.string.accounts_lists_drop_title),
|
||||
onDismiss = onDismiss,
|
||||
predictiveBack = true,
|
||||
) {
|
||||
PickerNote(stringResource(R.string.accounts_lists_drop_body))
|
||||
Spacer(Modifier.height(16.dp))
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.accounts_lists_drop_keep),
|
||||
summary = stringResource(R.string.accounts_lists_drop_keep_body),
|
||||
position = Position.Top,
|
||||
leading = { Icon(Icons.Rounded.Inventory2, contentDescription = null) },
|
||||
onClick = { onChoose(true) },
|
||||
)
|
||||
GroupedRow(
|
||||
title = errorTitle(stringResource(R.string.accounts_lists_drop_wipe)),
|
||||
summary = AnnotatedString(stringResource(R.string.accounts_lists_drop_wipe_body)),
|
||||
position = Position.Bottom,
|
||||
leading = {
|
||||
Icon(
|
||||
Icons.Rounded.DeleteOutline,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
},
|
||||
onClick = { onChoose(false) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** A destructive row's title, in the error colour the row itself cannot take. */
|
||||
@Composable
|
||||
private fun errorTitle(text: String): AnnotatedString {
|
||||
val error = MaterialTheme.colorScheme.error
|
||||
return remember(text, error) {
|
||||
buildAnnotatedString { withStyle(SpanStyle(color = error)) { append(text) } }
|
||||
}
|
||||
}
|
||||
@@ -1,225 +0,0 @@
|
||||
package de.jeanlucmakiola.agendula.ui.accounts
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.rounded.CloudSync
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.semantics.clearAndSetSemantics
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import de.jeanlucmakiola.agendula.R
|
||||
import de.jeanlucmakiola.agendula.data.tasks.room.AccountEntity
|
||||
import de.jeanlucmakiola.caldav.CalDavProvider
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
|
||||
/**
|
||||
* What an account is called on screen, and by what mark.
|
||||
*
|
||||
* `display_name` is `user@host` — the right identity for the system account and
|
||||
* for the sync trigger that keys off it, and far too long for a row title. Both
|
||||
* halves are stored separately anyway, so the screen shows the one that
|
||||
* identifies the account and demotes the other to the supporting line.
|
||||
*/
|
||||
internal data class AccountIdentity(
|
||||
val provider: CalDavProvider?,
|
||||
/** The name: a hosted service's brand, or the host of a server the user runs. */
|
||||
val title: String,
|
||||
/** The half [title] left out — the host under a brand, the software under a host. */
|
||||
val secondary: String?,
|
||||
/** Who, on that server. */
|
||||
val user: String?,
|
||||
)
|
||||
|
||||
internal fun AccountEntity.identity(): AccountIdentity {
|
||||
val url = principalUrl?.toHttpUrlOrNull()
|
||||
val provider = url?.let { CalDavProvider.forPrincipal(it) }
|
||||
val host = url?.host?.removePrefix("www.")
|
||||
val hosted = provider?.hosted == true
|
||||
return AccountIdentity(
|
||||
provider = provider,
|
||||
// The fallback is the stored name: an account with no principal URL
|
||||
// never got past discovery, so there is nothing better to call it.
|
||||
title = if (hosted) provider.label else host ?: displayName,
|
||||
secondary = if (hosted) host else provider?.label,
|
||||
user = username?.takeIf { it.isNotBlank() },
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The provider's logo: its mark, in white, on a disc of its own brand colour —
|
||||
* the 40dp leading avatar Calendula gives a synced calendar, in colour, so a
|
||||
* list of accounts is scannable by mark rather than by reading hostnames.
|
||||
*
|
||||
* White-on-brand rather than a brand-tinted glyph on a neutral chip, because it
|
||||
* is the shape the marks are actually drawn in and it is the one treatment that
|
||||
* needs no second colour for dark mode. Providers with no brand colour of their
|
||||
* own take the app's [MaterialTheme] accent.
|
||||
*
|
||||
* There are three treatments, and which one a provider gets is decided by how
|
||||
* that provider actually draws itself:
|
||||
*
|
||||
* 1. **A badge** — [CalDavProvider.badge] — fills the circle edge to edge in its
|
||||
* own colours. Fastmail's icon *is* a ring, so a disc behind it would be a
|
||||
* ring inside a circle, and knocking it back to white would throw away the
|
||||
* logo's larger half.
|
||||
* 2. **A mark** — [CalDavProvider.mark] — is tinted white on the brand's disc,
|
||||
* which is how Nextcloud, Apple and Posteo draw these marks themselves.
|
||||
* 3. **A lettermark** for everything else. Material's `AlternateEmail` was doing
|
||||
* duty for six providers at once, so a list meant to be read by mark showed
|
||||
* one glyph six times; the brand's own initial tells them apart and claims
|
||||
* nothing. The case comes from [CalDavProvider.label], which is why iCloud
|
||||
* and mailbox.org keep their lowercase letterforms.
|
||||
*
|
||||
* A real logo beats a letter; an *approximated* logo beats neither, which is why
|
||||
* the rest wait for their own art rather than for a good guess at it — and why
|
||||
* every mark here is generated from the vendor's own file, not traced by eye.
|
||||
*/
|
||||
@Composable
|
||||
internal fun ProviderLogo(provider: CalDavProvider?, size: Dp = 40.dp) {
|
||||
val badge = provider?.badge
|
||||
if (badge != null) {
|
||||
// ⚠️ On white, not on nothing. The badge is a ring with a transparent
|
||||
// middle, drawn for a white page — dropped straight onto the row it lets
|
||||
// the surface through, and in dark mode the navy envelope inside it goes
|
||||
// very nearly invisible. White is the background the art is drawn for, so
|
||||
// it is the background it gets, in both themes.
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(size)
|
||||
.clip(CircleShape)
|
||||
.background(Color.White),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(badge),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(size),
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
val accent = provider?.accent
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(size)
|
||||
.clip(CircleShape)
|
||||
.background(accent ?: MaterialTheme.colorScheme.primary),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
val tint = if (accent != null) Color.White else MaterialTheme.colorScheme.onPrimary
|
||||
val mark = provider?.mark
|
||||
when {
|
||||
mark != null -> Icon(
|
||||
painter = painterResource(mark.res),
|
||||
contentDescription = null,
|
||||
tint = tint,
|
||||
modifier = Modifier.size(size * mark.fraction),
|
||||
)
|
||||
|
||||
// A server we know nothing about has no initial to wear.
|
||||
provider == null -> Icon(
|
||||
imageVector = Icons.Rounded.CloudSync,
|
||||
contentDescription = null,
|
||||
tint = tint,
|
||||
modifier = Modifier.size(size * GLYPH_FRACTION),
|
||||
)
|
||||
|
||||
else -> Text(
|
||||
text = provider.letter,
|
||||
color = tint,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontSize = with(LocalDensity.current) { (size * LETTER_FRACTION).toSp() },
|
||||
// The name is on the row beside it. A screen reader announcing a
|
||||
// bare "F" before "Fastmail" is noise, not information.
|
||||
modifier = Modifier.clearAndSetSemantics { },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A provider whose official icon is a finished badge, colours and all. */
|
||||
private val CalDavProvider.badge: Int?
|
||||
get() = when (this) {
|
||||
CalDavProvider.FASTMAIL -> R.drawable.ic_provider_fastmail
|
||||
CalDavProvider.MAILBOX_ORG -> R.drawable.ic_provider_mailbox
|
||||
else -> null
|
||||
}
|
||||
|
||||
/**
|
||||
* The letter a service is known by.
|
||||
*
|
||||
* Its own initial, except where the service's actual mark *is* a different
|
||||
* letter: Yandex's is a Cyrillic Я, which is something we can set rather than
|
||||
* art we would have to trace — the only vector they publish is a 64px raster.
|
||||
*/
|
||||
private val CalDavProvider.letter: String
|
||||
get() = when (this) {
|
||||
CalDavProvider.YANDEX -> "Я"
|
||||
else -> label.take(1)
|
||||
}
|
||||
|
||||
/**
|
||||
* A monochrome mark and how much of the disc it is given.
|
||||
*
|
||||
* The fraction is not one number because the marks are not one shape: a wide
|
||||
* mark squared off into the same box reads smaller than a compact one, so it is
|
||||
* given more room to land on the same optical weight.
|
||||
*/
|
||||
private data class Mark(val res: Int, val fraction: Float)
|
||||
|
||||
private val CalDavProvider.mark: Mark?
|
||||
get() = when (this) {
|
||||
CalDavProvider.NEXTCLOUD -> Mark(R.drawable.ic_provider_nextcloud, WIDE_MARK_FRACTION)
|
||||
CalDavProvider.ICLOUD -> Mark(R.drawable.ic_provider_icloud, WIDE_MARK_FRACTION)
|
||||
CalDavProvider.POSTEO -> Mark(R.drawable.ic_provider_posteo, GLYPH_FRACTION)
|
||||
else -> null
|
||||
}
|
||||
|
||||
/**
|
||||
* The provider's own brand colour, where it publishes one recognisable enough to
|
||||
* be worth carrying. Null means "we would be inventing it" — Baïkal, DAViCal and
|
||||
* SOGo have no colour anyone would recognise, so they take the theme's accent
|
||||
* instead of a made-up one.
|
||||
*
|
||||
* Each is dark enough to carry a white mark, which is the whole treatment: no
|
||||
* dark-mode variant is needed because neither colour in the pair moves.
|
||||
*/
|
||||
private val CalDavProvider.accent: Color?
|
||||
get() = when (this) {
|
||||
CalDavProvider.NEXTCLOUD -> Color(0xFF0082C9)
|
||||
CalDavProvider.ICLOUD -> Color(0xFF007AFF)
|
||||
CalDavProvider.GOOGLE -> Color(0xFF1A73E8)
|
||||
CalDavProvider.FASTMAIL -> Color(0xFF2B6CB0)
|
||||
CalDavProvider.MAILBOX_ORG -> Color(0xFF0069B4)
|
||||
// Their own, off their app icon — not the darker green that was
|
||||
// guessed at before the art arrived.
|
||||
CalDavProvider.POSTEO -> Color(0xFFA9D158)
|
||||
CalDavProvider.ZOHO -> Color(0xFFE42527)
|
||||
CalDavProvider.YANDEX -> Color(0xFFFF2500)
|
||||
CalDavProvider.BAIKAL, CalDavProvider.DAVICAL, CalDavProvider.SOGO -> null
|
||||
}
|
||||
|
||||
/** The mark sits on the disc the way a launcher icon does — a little over half. */
|
||||
private const val GLYPH_FRACTION = 0.55f
|
||||
|
||||
/** A letter reads smaller than a glyph of the same box, so it is given less. */
|
||||
private const val LETTER_FRACTION = 0.44f
|
||||
|
||||
/** A wide mark squared off into the same box has to be given more to match. */
|
||||
private const val WIDE_MARK_FRACTION = 0.72f
|
||||
@@ -1,318 +0,0 @@
|
||||
package de.jeanlucmakiola.agendula.ui.accounts
|
||||
|
||||
import android.text.format.DateUtils
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.rounded.Login
|
||||
import androidx.compose.material.icons.rounded.Add
|
||||
import androidx.compose.material.icons.rounded.CloudSync
|
||||
import androidx.compose.material.icons.rounded.Storage
|
||||
import androidx.compose.material.icons.rounded.Sync
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import de.jeanlucmakiola.agendula.R
|
||||
import de.jeanlucmakiola.agendula.data.prefs.SYNC_INTERVAL_PRESETS
|
||||
import de.jeanlucmakiola.agendula.data.sync.SyncFailure
|
||||
import de.jeanlucmakiola.agendula.data.tasks.room.AccountEntity
|
||||
import de.jeanlucmakiola.agendula.ui.common.OnResume
|
||||
import de.jeanlucmakiola.agendula.ui.settings.SettingsHint
|
||||
import de.jeanlucmakiola.floret.components.CollapsingScaffold
|
||||
import de.jeanlucmakiola.floret.components.GroupedListInset
|
||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||
import de.jeanlucmakiola.floret.components.OptionPicker
|
||||
import de.jeanlucmakiola.floret.components.Position
|
||||
import de.jeanlucmakiola.floret.components.positionOf
|
||||
|
||||
/** The CalDAV accounts this device syncs with. */
|
||||
@Composable
|
||||
internal fun AccountsScreen(
|
||||
onAddAccount: () -> Unit,
|
||||
onSignInAgain: (AccountEntity) -> Unit,
|
||||
onOpenAccount: (Long) -> Unit,
|
||||
onOpenStorage: () -> Unit,
|
||||
onBack: () -> Unit,
|
||||
viewModel: AccountsViewModel,
|
||||
) {
|
||||
val accounts by viewModel.accounts.collectAsStateWithLifecycle()
|
||||
val usable by viewModel.accountsUsable.collectAsStateWithLifecycle()
|
||||
val syncInterval by viewModel.syncIntervalMinutes.collectAsStateWithLifecycle()
|
||||
val push by viewModel.push.collectAsStateWithLifecycle()
|
||||
var showInterval by remember { mutableStateOf(false) }
|
||||
var showPush by remember { mutableStateOf(false) }
|
||||
OnResume(viewModel::refreshPush)
|
||||
|
||||
CollapsingScaffold(
|
||||
title = stringResource(R.string.settings_section_accounts),
|
||||
onBack = onBack,
|
||||
) {
|
||||
val loaded = accounts ?: return@CollapsingScaffold
|
||||
|
||||
if (loaded.isEmpty()) {
|
||||
Icon(
|
||||
Icons.Rounded.CloudSync,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.padding(horizontal = GroupedListInset),
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Text(
|
||||
stringResource(R.string.accounts_empty_title),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
modifier = Modifier.padding(horizontal = GroupedListInset),
|
||||
)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(
|
||||
stringResource(R.string.accounts_empty_body),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = GroupedListInset),
|
||||
)
|
||||
Spacer(Modifier.height(24.dp))
|
||||
} else {
|
||||
loaded.forEachIndexed { index, row ->
|
||||
val account = row.account
|
||||
val identity = account.identity()
|
||||
GroupedRow(
|
||||
title = identity.title,
|
||||
// One line, so it says the most useful thing it can. A row
|
||||
// on its way out has one piece of news; an account holding
|
||||
// unread sync reports has another, and either outranks
|
||||
// "synced 5 minutes ago" — but neither outranks a sign-in
|
||||
// that has stopped, which `syncState` already puts first.
|
||||
summary = when {
|
||||
row.removing -> stringResource(R.string.accounts_removing)
|
||||
row.notices.isNotEmpty() && !row.needsSignIn -> accountSummary(
|
||||
identity.user,
|
||||
pluralStringResource(
|
||||
R.plurals.accounts_notices,
|
||||
row.notices.size,
|
||||
row.notices.size,
|
||||
),
|
||||
)
|
||||
else -> accountSummary(identity.user, syncState(row))
|
||||
},
|
||||
position = positionOf(index, loaded.size),
|
||||
// A row whose account is on its way out is not a row you can
|
||||
// act on, so it stops looking like one: dimmed, its action
|
||||
// replaced by the progress, and nothing to tap.
|
||||
dimmed = row.removing,
|
||||
leading = { ProviderLogo(identity.provider) },
|
||||
trailing = {
|
||||
when {
|
||||
row.removing -> CircularProgressIndicator(Modifier.size(20.dp))
|
||||
!usable -> Unit
|
||||
row.needsSignIn -> IconButton(onClick = { onSignInAgain(account) }) {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Rounded.Login,
|
||||
contentDescription = stringResource(R.string.accounts_sign_in_again),
|
||||
)
|
||||
}
|
||||
else -> IconButton(onClick = { viewModel.syncNow(account) }) {
|
||||
Icon(
|
||||
Icons.Rounded.Sync,
|
||||
contentDescription = stringResource(R.string.accounts_sync_now),
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
onClick = { onOpenAccount(account.id) }.takeUnless { row.removing },
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(24.dp))
|
||||
if (usable) {
|
||||
val pushState = push
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.accounts_sync_interval),
|
||||
summary = syncIntervalLabel(syncInterval),
|
||||
position = if (pushState == null) Position.Alone else Position.Top,
|
||||
onClick = { showInterval = true },
|
||||
)
|
||||
if (pushState != null) {
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.accounts_push),
|
||||
summary = pushSummary(pushState),
|
||||
position = Position.Bottom,
|
||||
onClick = { showPush = true },
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(24.dp))
|
||||
}
|
||||
}
|
||||
|
||||
if (usable) {
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.accounts_add),
|
||||
position = Position.Alone,
|
||||
leading = { Icon(Icons.Rounded.Add, contentDescription = null) },
|
||||
onClick = onAddAccount,
|
||||
)
|
||||
} else {
|
||||
ExternalStorageNotice(onOpenStorage)
|
||||
}
|
||||
}
|
||||
|
||||
if (showInterval) {
|
||||
OptionPicker(
|
||||
title = stringResource(R.string.accounts_sync_interval),
|
||||
options = SYNC_INTERVAL_PRESETS,
|
||||
selected = syncInterval,
|
||||
label = { syncIntervalLabel(it) },
|
||||
header = {
|
||||
SettingsHint(stringResource(R.string.accounts_sync_interval_hint))
|
||||
Spacer(Modifier.height(8.dp))
|
||||
},
|
||||
onSelect = viewModel::setSyncInterval,
|
||||
onDismiss = { showInterval = false },
|
||||
)
|
||||
}
|
||||
|
||||
val pushState = push
|
||||
if (showPush && pushState != null) {
|
||||
PushPicker(
|
||||
push = pushState,
|
||||
onSelect = viewModel::choosePushDistributor,
|
||||
onDismiss = { showPush = false },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PushPicker(
|
||||
push: AccountsViewModel.PushUi,
|
||||
onSelect: (String?) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
OptionPicker(
|
||||
title = stringResource(R.string.accounts_push),
|
||||
options = listOf(PUSH_OFF) + push.installed.map { it.packageName },
|
||||
// Nothing is ticked while push is on but no distributor is chosen yet.
|
||||
selected = if (push.enabled) push.current?.packageName else PUSH_OFF,
|
||||
label = { option ->
|
||||
if (option == PUSH_OFF) {
|
||||
stringResource(R.string.accounts_push_off)
|
||||
} else {
|
||||
push.installed.first { it.packageName == option }.label
|
||||
}
|
||||
},
|
||||
header = {
|
||||
SettingsHint(stringResource(R.string.accounts_push_hint))
|
||||
if (push.installed.isEmpty()) SettingsHint(stringResource(R.string.accounts_push_none_installed))
|
||||
Spacer(Modifier.height(8.dp))
|
||||
},
|
||||
onSelect = { option -> onSelect(option.takeUnless { it == PUSH_OFF }) },
|
||||
onDismiss = onDismiss,
|
||||
)
|
||||
}
|
||||
|
||||
/** The picker's "Off" row, which no package can be named. */
|
||||
private const val PUSH_OFF = ""
|
||||
|
||||
@Composable
|
||||
internal fun pushSummary(push: AccountsViewModel.PushUi): String {
|
||||
val current = push.current
|
||||
val lists = push.lists
|
||||
return when {
|
||||
!push.enabled -> stringResource(R.string.accounts_push_off)
|
||||
push.needsChoice -> stringResource(R.string.accounts_push_choose)
|
||||
current == null -> stringResource(R.string.accounts_push_no_distributor)
|
||||
lists.capable == 0 -> stringResource(R.string.accounts_push_unsupported)
|
||||
lists.subscribed == 0 -> stringResource(R.string.accounts_push_pending)
|
||||
lists.subscribed < lists.synced -> pluralStringResource(
|
||||
R.plurals.accounts_push_partial,
|
||||
lists.synced,
|
||||
lists.subscribed,
|
||||
lists.synced,
|
||||
current.label,
|
||||
)
|
||||
else -> stringResource(R.string.accounts_push_via, current.label)
|
||||
}
|
||||
}
|
||||
|
||||
/** The same, for one account's lists. */
|
||||
@Composable
|
||||
internal fun accountPushSummary(push: AccountsViewModel.PushUi, lists: AccountsViewModel.ListPush): String =
|
||||
when {
|
||||
!push.enabled -> stringResource(R.string.accounts_push_off)
|
||||
push.needsChoice -> stringResource(R.string.accounts_push_choose)
|
||||
push.current == null -> stringResource(R.string.accounts_push_no_distributor)
|
||||
lists.capable == 0 -> stringResource(R.string.accounts_push_account_unsupported)
|
||||
lists.subscribed == 0 -> stringResource(R.string.accounts_push_pending)
|
||||
lists.subscribed < lists.synced -> pluralStringResource(
|
||||
R.plurals.accounts_push_account_partial,
|
||||
lists.synced,
|
||||
lists.subscribed,
|
||||
lists.synced,
|
||||
)
|
||||
else -> stringResource(R.string.accounts_push_account_on)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun syncIntervalLabel(minutes: Int): String = when {
|
||||
minutes <= 0 -> stringResource(R.string.accounts_sync_interval_manual)
|
||||
minutes % 60 == 0 -> pluralStringResource(R.plurals.accounts_sync_every_hours, minutes / 60, minutes / 60)
|
||||
else -> pluralStringResource(R.plurals.accounts_sync_every_minutes, minutes, minutes)
|
||||
}
|
||||
|
||||
/** In External mode accounts stay, but sync into a store nothing is showing. */
|
||||
@Composable
|
||||
internal fun ExternalStorageNotice(onOpenStorage: () -> Unit) {
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.accounts_external_storage_title),
|
||||
summary = stringResource(R.string.accounts_external_storage),
|
||||
position = Position.Alone,
|
||||
leading = { Icon(Icons.Rounded.Storage, contentDescription = null) },
|
||||
onClick = onOpenStorage,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Never the raw values: `lastSyncError` is an exception string and `lastSyncAt`
|
||||
* renders as an ISO-8601 UTC instant, and both bypass `strings.xml` entirely.
|
||||
*/
|
||||
@Composable
|
||||
internal fun syncState(row: AccountsViewModel.AccountRow): String = when {
|
||||
// Distinct from a failed sync on purpose: this one has stopped retrying, and
|
||||
// only the user can restart it.
|
||||
row.needsSignIn -> stringResource(R.string.accounts_needs_sign_in)
|
||||
row.account.lastSyncError != null -> failureText(SyncFailure.of(row.account.lastSyncError))
|
||||
row.account.lastSyncAt != null -> DateUtils.getRelativeTimeSpanString(
|
||||
row.account.lastSyncAt.toEpochMilliseconds(),
|
||||
System.currentTimeMillis(),
|
||||
DateUtils.MINUTE_IN_MILLIS,
|
||||
).toString()
|
||||
|
||||
else -> stringResource(R.string.accounts_never_synced)
|
||||
}
|
||||
|
||||
/** Why the last sync failed, by class — never the engine's own words. */
|
||||
@Composable
|
||||
private fun failureText(failure: SyncFailure): String = when (failure.kind) {
|
||||
SyncFailure.Kind.SIGN_IN -> stringResource(R.string.accounts_failed_sign_in)
|
||||
SyncFailure.Kind.UNREACHABLE -> stringResource(R.string.accounts_failed_unreachable)
|
||||
SyncFailure.Kind.CERTIFICATE -> stringResource(R.string.accounts_failed_certificate)
|
||||
SyncFailure.Kind.SERVER -> failure.httpCode
|
||||
?.let { stringResource(R.string.accounts_failed_server_code, it) }
|
||||
?: stringResource(R.string.accounts_failed_server)
|
||||
SyncFailure.Kind.MISCONFIGURED -> stringResource(R.string.accounts_failed_misconfigured)
|
||||
SyncFailure.Kind.OTHER -> stringResource(R.string.accounts_sync_failed)
|
||||
}
|
||||
|
||||
/** Who, then how it last went — the two things the title does not already say. */
|
||||
@Composable
|
||||
private fun accountSummary(user: String?, state: String): String =
|
||||
if (user == null) state else stringResource(R.string.accounts_summary, user, state)
|
||||
@@ -1,356 +0,0 @@
|
||||
package de.jeanlucmakiola.agendula.ui.accounts
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import de.jeanlucmakiola.agendula.data.di.IoDispatcher
|
||||
import de.jeanlucmakiola.agendula.data.prefs.DEFAULT_SYNC_INTERVAL_MINUTES
|
||||
import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.agendula.data.sync.AccountRepository
|
||||
import de.jeanlucmakiola.agendula.data.sync.AccountStateStore
|
||||
import de.jeanlucmakiola.agendula.data.sync.SyncAvailability
|
||||
import de.jeanlucmakiola.agendula.data.sync.SyncNotice
|
||||
import de.jeanlucmakiola.agendula.data.sync.SyncNoticeStore
|
||||
import de.jeanlucmakiola.agendula.data.sync.SyncTrigger
|
||||
import de.jeanlucmakiola.agendula.data.sync.push.PushDistributors
|
||||
import de.jeanlucmakiola.agendula.data.sync.push.PushRegistrar
|
||||
import de.jeanlucmakiola.agendula.data.sync.push.PushStore
|
||||
import de.jeanlucmakiola.agendula.data.tasks.room.AccountEntity
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import de.jeanlucmakiola.caldav.CalDavDiscovery
|
||||
import de.jeanlucmakiola.caldav.TaskCollection
|
||||
import okhttp3.HttpUrl
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class AccountsViewModel @Inject constructor(
|
||||
private val repository: AccountRepository,
|
||||
private val syncTrigger: SyncTrigger,
|
||||
private val accountState: AccountStateStore,
|
||||
private val notices: SyncNoticeStore,
|
||||
private val settings: SettingsPrefs,
|
||||
private val distributors: PushDistributors,
|
||||
private val pushRegistrar: PushRegistrar,
|
||||
pushStore: PushStore,
|
||||
availability: SyncAvailability,
|
||||
@IoDispatcher io: CoroutineDispatcher,
|
||||
) : ViewModel() {
|
||||
|
||||
/** Minutes between background syncs; 0 = manual only. */
|
||||
val syncIntervalMinutes: StateFlow<Int> = settings.settings.map { it.syncIntervalMinutes }.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(STOP_TIMEOUT_MILLIS),
|
||||
initialValue = DEFAULT_SYNC_INTERVAL_MINUTES,
|
||||
)
|
||||
|
||||
/** Store the new interval and put every account on it. */
|
||||
fun setSyncInterval(minutes: Int) {
|
||||
viewModelScope.launch {
|
||||
settings.setSyncIntervalMinutes(minutes)
|
||||
repository.rescheduleAll(intervalChanged = true)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* An account's synced lists, how many its server offers push for, and how
|
||||
* many actually hold a subscription.
|
||||
*/
|
||||
data class ListPush(val synced: Int, val capable: Int, val subscribed: Int) {
|
||||
companion object {
|
||||
val NONE = ListPush(synced = 0, capable = 0, subscribed = 0)
|
||||
}
|
||||
}
|
||||
|
||||
private val listPush: Flow<Map<Long, ListPush>> =
|
||||
combine(repository.observeSyncedLists(), pushStore.observe()) { lists, pushes ->
|
||||
lists.groupBy { it.accountId!! }.mapValues { (_, synced) ->
|
||||
ListPush(
|
||||
synced = synced.size,
|
||||
capable = synced.count { pushes[it.id]?.support != null },
|
||||
subscribed = synced.count { pushes[it.id]?.subscription != null },
|
||||
)
|
||||
}
|
||||
}.distinctUntilChanged()
|
||||
|
||||
/** Push as the Accounts screen shows it. */
|
||||
data class PushUi(
|
||||
val enabled: Boolean,
|
||||
val installed: List<PushDistributors.Distributor>,
|
||||
/** The distributor in use; null when push is off or none is chosen. */
|
||||
val current: PushDistributors.Distributor?,
|
||||
val lists: ListPush,
|
||||
) {
|
||||
/** On, distributors installed, and none chosen or set as default. */
|
||||
val needsChoice: Boolean get() = enabled && current == null && installed.isNotEmpty()
|
||||
}
|
||||
|
||||
private data class DistributorState(
|
||||
val enabled: Boolean,
|
||||
val installed: List<PushDistributors.Distributor>,
|
||||
val current: PushDistributors.Distributor?,
|
||||
)
|
||||
|
||||
/** Distributors are installed and removed outside the app; bumped on resume. */
|
||||
private val pushRefresh = MutableStateFlow(0)
|
||||
|
||||
/** Package queries only on a setting change or a refresh, and off the main thread. */
|
||||
private val distributorState: Flow<DistributorState> = combine(
|
||||
settings.settings.map { it.pushEnabled }.distinctUntilChanged(),
|
||||
pushRefresh,
|
||||
) { enabled, _ ->
|
||||
val installed = distributors.installed()
|
||||
val current = distributors.saved().takeIf { enabled }?.let { chosen ->
|
||||
installed.firstOrNull { it.packageName == chosen }
|
||||
?: PushDistributors.Distributor(chosen, distributors.labelOf(chosen))
|
||||
}
|
||||
DistributorState(enabled, installed, current)
|
||||
}.flowOn(io)
|
||||
|
||||
val push: StateFlow<PushUi?> = combine(distributorState, listPush) { state, perAccount ->
|
||||
PushUi(
|
||||
enabled = state.enabled,
|
||||
installed = state.installed,
|
||||
current = state.current,
|
||||
lists = ListPush(
|
||||
synced = perAccount.values.sumOf { it.synced },
|
||||
capable = perAccount.values.sumOf { it.capable },
|
||||
subscribed = perAccount.values.sumOf { it.subscribed },
|
||||
),
|
||||
)
|
||||
}.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(STOP_TIMEOUT_MILLIS),
|
||||
initialValue = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* Re-reads the installed distributors. With push on and none chosen yet, a
|
||||
* newly installed one is adopted and registered first, so the row never
|
||||
* names a distributor nothing has registered with.
|
||||
*/
|
||||
fun refreshPush() {
|
||||
viewModelScope.launch {
|
||||
if (settings.settings.first().pushEnabled && distributors.saved() == null) {
|
||||
runCatching { pushRegistrar.updateAll() }
|
||||
}
|
||||
pushRefresh.update { it + 1 }
|
||||
}
|
||||
}
|
||||
|
||||
/** @param packageName the distributor to use, or null to turn push off. */
|
||||
fun choosePushDistributor(packageName: String?) {
|
||||
viewModelScope.launch {
|
||||
if (packageName == null) distributors.disable() else distributors.select(packageName)
|
||||
runCatching { pushRegistrar.updateAll() }
|
||||
pushRefresh.update { it + 1 }
|
||||
}
|
||||
}
|
||||
|
||||
/** False in External storage mode, where accounts are kept but neither added nor synced. */
|
||||
val accountsUsable: StateFlow<Boolean> = availability.observe().stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(STOP_TIMEOUT_MILLIS),
|
||||
initialValue = true,
|
||||
)
|
||||
|
||||
/** An account row, plus the two things the row cannot read from the entity. */
|
||||
data class AccountRow(
|
||||
val account: AccountEntity,
|
||||
val needsSignIn: Boolean,
|
||||
/**
|
||||
* A removal is under way for this account.
|
||||
*
|
||||
* The row survives it: `remove()` revokes the app password over the
|
||||
* network before it touches any store, so several seconds pass — on a
|
||||
* server that stalls, longer — during which the row sat there looking
|
||||
* completely untouched.
|
||||
*/
|
||||
val removing: Boolean = false,
|
||||
/**
|
||||
* What the last syncs destroyed or gave up on, newest first.
|
||||
*
|
||||
* ⚠️ On the row rather than in a flow of its own, because it has to
|
||||
* survive the account it belongs to going away: an account is removed
|
||||
* by id, and a notice keyed to an id nothing lists any more is a line
|
||||
* of text about nothing.
|
||||
*/
|
||||
val notices: List<SyncNotice> = emptyList(),
|
||||
val lists: ListPush = ListPush.NONE,
|
||||
)
|
||||
|
||||
/**
|
||||
* Accounts whose removal has started and not finished.
|
||||
*
|
||||
* ⚠️ Also the double-tap guard. Nothing else stops a second Remove from
|
||||
* starting a second revocation of a credential the first one is already
|
||||
* handing back — harmless since `c2166b9`, because the sequence is
|
||||
* idempotent either way, but it spends a second network round trip and
|
||||
* leaves the user watching two things happen to one account.
|
||||
*/
|
||||
private val _removing = MutableStateFlow<Set<Long>>(emptySet())
|
||||
|
||||
/**
|
||||
* `null` until the first load, so the empty state does not flash.
|
||||
*
|
||||
* ⚠️ Observed, not fetched. The sync that a tap on this screen starts
|
||||
* finishes on a background thread some seconds later, and a snapshot taken
|
||||
* when the screen opened cannot show it — the row went on saying "never
|
||||
* synced" until the user left and came back. This also carries a *background*
|
||||
* sync, and a 401 that stops an account, onto a screen already open.
|
||||
*/
|
||||
val accounts: StateFlow<List<AccountRow>?> =
|
||||
combine(
|
||||
repository.observeAll(),
|
||||
accountState.observeNeedingSignIn(),
|
||||
_removing,
|
||||
notices.observeAll(),
|
||||
listPush,
|
||||
) { accounts, stopped, removing, allNotices, perAccount ->
|
||||
val byAccount = allNotices.groupBy { it.accountId }
|
||||
accounts.map {
|
||||
AccountRow(
|
||||
account = it,
|
||||
needsSignIn = it.id in stopped,
|
||||
removing = it.id in removing,
|
||||
notices = byAccount[it.id].orEmpty(),
|
||||
lists = perAccount[it.id] ?: ListPush.NONE,
|
||||
)
|
||||
}
|
||||
}.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(STOP_TIMEOUT_MILLIS),
|
||||
initialValue = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* The app's own sync trigger.
|
||||
*
|
||||
* Not `ContentResolver.requestSync`: that is gated behind
|
||||
* `hasAuthorityAccess()` at our targetSdk and returns silently when it
|
||||
* refuses, which would leave the user pressing a button that does nothing.
|
||||
*/
|
||||
fun syncNow(account: AccountEntity) {
|
||||
// Expedited: the user is looking at the button. Every other trigger is
|
||||
// ordinary work, so the exhaustible per-app quota is spent here or not
|
||||
// at all.
|
||||
syncTrigger.enqueue(account.displayName, expedited = true)
|
||||
}
|
||||
|
||||
/** The user has read what the last sync changed. */
|
||||
fun dismissNotices(account: AccountEntity) {
|
||||
viewModelScope.launch { notices.dismiss(account.id) }
|
||||
}
|
||||
|
||||
/** Clears a quarantined task's failure count and syncs, so it is tried again. */
|
||||
fun retry(notice: SyncNotice) {
|
||||
viewModelScope.launch { repository.retryQuarantined(notice.accountId, notice.key) }
|
||||
}
|
||||
|
||||
fun remove(account: AccountEntity, deleteLocalData: Boolean) {
|
||||
if (account.id in _removing.value) return
|
||||
_removing.update { it + account.id }
|
||||
// No refresh: the row disappears because the query behind `accounts`
|
||||
// re-emits.
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
repository.remove(account.id, account.displayName, deleteLocalData)
|
||||
} finally {
|
||||
// ⚠️ In a `finally`, and not only on the happy path. `remove()`
|
||||
// finishes its destructive tail uncancellable, so the id would
|
||||
// otherwise be stranded in the set by the one case that reaches
|
||||
// here without completing normally — leaving a row that is gone
|
||||
// from Room but pending for ever if it ever came back.
|
||||
_removing.update { it - account.id }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The account's collections, re-read from the server for the lists picker. */
|
||||
sealed interface ListsState {
|
||||
data object Loading : ListsState
|
||||
|
||||
data class Ready(
|
||||
val accountId: Long,
|
||||
val collections: List<TaskCollection>,
|
||||
val attached: Set<String>,
|
||||
val selected: Set<HttpUrl>,
|
||||
) : ListsState {
|
||||
/** Synced lists this selection would stop syncing — the ones worth asking about. */
|
||||
val dropping: Boolean
|
||||
get() = collections.any { it.url.toString() in attached && it.url !in selected }
|
||||
}
|
||||
|
||||
data object NeedsSignIn : ListsState
|
||||
|
||||
data class Failed(val cause: CalDavDiscovery.Outcome.Cause?) : ListsState
|
||||
}
|
||||
|
||||
private val _lists = MutableStateFlow<ListsState?>(null)
|
||||
|
||||
/** Null while the picker is closed. */
|
||||
val lists: StateFlow<ListsState?> = _lists
|
||||
|
||||
fun openLists(accountId: Long) {
|
||||
_lists.value = ListsState.Loading
|
||||
viewModelScope.launch {
|
||||
val loaded = when (val found = repository.collections(accountId)) {
|
||||
is AccountRepository.Collections.Found -> ListsState.Ready(
|
||||
accountId = accountId,
|
||||
collections = found.collections,
|
||||
attached = found.attached,
|
||||
selected = found.collections
|
||||
.filter { it.url.toString() in found.attached }
|
||||
.map { it.url }
|
||||
.toSet(),
|
||||
)
|
||||
AccountRepository.Collections.NeedsSignIn -> ListsState.NeedsSignIn
|
||||
is AccountRepository.Collections.Failed -> ListsState.Failed(found.cause)
|
||||
}
|
||||
// Closed while it loaded: stay closed.
|
||||
if (_lists.value == ListsState.Loading) _lists.value = loaded
|
||||
}
|
||||
}
|
||||
|
||||
fun toggleList(url: HttpUrl) {
|
||||
_lists.update { state ->
|
||||
if (state !is ListsState.Ready) return@update state
|
||||
val selected = if (url in state.selected) state.selected - url else state.selected + url
|
||||
state.copy(selected = selected)
|
||||
}
|
||||
}
|
||||
|
||||
fun closeLists() {
|
||||
_lists.value = null
|
||||
}
|
||||
|
||||
/** @param keepUnticked what happens to the tasks of lists that stop syncing. */
|
||||
fun saveLists(keepUnticked: Boolean) {
|
||||
val state = _lists.value as? ListsState.Ready ?: return
|
||||
_lists.value = null
|
||||
viewModelScope.launch {
|
||||
repository.setSyncedCollections(
|
||||
accountId = state.accountId,
|
||||
offered = state.collections,
|
||||
selected = state.selected,
|
||||
keepUnticked = keepUnticked,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
/** Survives a configuration change without re-subscribing. */
|
||||
const val STOP_TIMEOUT_MILLIS = 5_000L
|
||||
}
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
package de.jeanlucmakiola.agendula.ui.accounts.add
|
||||
|
||||
import de.jeanlucmakiola.caldav.ServerQuirk
|
||||
|
||||
/**
|
||||
* Something the add-account flow has to tell the user, in a form the UI can
|
||||
* translate.
|
||||
*
|
||||
* ⚠️ A type, not a `String`, and not a `@StringRes Int` either. The flow used to
|
||||
* build its sentences in the ViewModel, so a dozen of them shipped in English to
|
||||
* every locale — the same defect `CalDavDiscovery.Outcome.Cause` was introduced
|
||||
* to fix, which it only ever fixed for the address step. Nothing lints for it:
|
||||
* `HardcodedText` reads XML layout attributes, and this app has none. Making the
|
||||
* state fields carry this makes a literal a compile error, which is the only
|
||||
* guard available.
|
||||
*
|
||||
* A resource id would work too, but two of these need a format argument and an
|
||||
* `Int` accepts any other `Int` — this way the argument travels with the message
|
||||
* that needs it, and a test can assert on meaning rather than on prose.
|
||||
*/
|
||||
sealed interface AddAccountMessage {
|
||||
|
||||
/** Something is happening, and the step says which. */
|
||||
sealed interface Progress : AddAccountMessage {
|
||||
data object Discovering : Progress
|
||||
data object SigningIn : Progress
|
||||
data object ReadingLists : Progress
|
||||
data object Saving : Progress
|
||||
}
|
||||
|
||||
/** Something went wrong, and the step says what. */
|
||||
sealed interface Problem : AddAccountMessage
|
||||
|
||||
data object GoogleUnsupported : Problem
|
||||
data object AlreadyExists : Problem
|
||||
data object ExternalStorage : Problem
|
||||
data object NoUsableLists : Problem
|
||||
data object NotSaved : Problem
|
||||
data object KeystoreRefused : Problem
|
||||
data object CredentialsRejected : Problem
|
||||
data object BrowserApprovalExpired : Problem
|
||||
data object BrowserRateLimited : Problem
|
||||
data object BrowserMaintenance : Problem
|
||||
data object BrowserFailed : Problem
|
||||
|
||||
/** No browser could be opened at all — AOSP, GrapheneOS, a locked-down profile. */
|
||||
data object BrowserUnavailable : Problem
|
||||
|
||||
/**
|
||||
* A home set on a host the credential is not scoped to.
|
||||
*
|
||||
* The host travels with the message rather than being baked into a sentence,
|
||||
* so the translation decides where it goes.
|
||||
*/
|
||||
data class OutsideCredentialScope(val host: String) : Problem
|
||||
|
||||
/** A provider whose real requirement is not "wrong password". */
|
||||
data class Quirk(val quirk: ServerQuirk) : Problem
|
||||
}
|
||||
@@ -1,172 +0,0 @@
|
||||
package de.jeanlucmakiola.agendula.ui.accounts.add
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringArrayResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.text.input.VisualTransformation
|
||||
import androidx.compose.ui.unit.dp
|
||||
import de.jeanlucmakiola.agendula.R
|
||||
import de.jeanlucmakiola.caldav.ServerQuirk
|
||||
import de.jeanlucmakiola.floret.components.GroupedListInset
|
||||
import de.jeanlucmakiola.floret.components.GroupedSurface
|
||||
import de.jeanlucmakiola.floret.components.InstructionSteps
|
||||
import de.jeanlucmakiola.floret.components.InlineTextField
|
||||
import de.jeanlucmakiola.floret.components.Position
|
||||
|
||||
/**
|
||||
* The family's text input: a tonal grouped surface with a borderless field in
|
||||
* it, never Material's outlined box.
|
||||
*
|
||||
* The label sits above the value rather than floating into a notch, because a
|
||||
* `GroupedSurface` has no outline for a notch to interrupt.
|
||||
*/
|
||||
@Composable
|
||||
internal fun FieldRow(
|
||||
label: String,
|
||||
value: String,
|
||||
onValueChange: (String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
position: Position = Position.Alone,
|
||||
placeholder: String = "",
|
||||
error: String? = null,
|
||||
hint: String? = null,
|
||||
keyboardType: KeyboardType = KeyboardType.Text,
|
||||
onImeAction: (() -> Unit)? = null,
|
||||
) {
|
||||
// ⚠️ KeyboardType.Password only tells the IME to drop suggestions; it does
|
||||
// not mask anything. Without the transformation the app password renders in
|
||||
// the clear on screen.
|
||||
val masked = keyboardType == KeyboardType.Password
|
||||
Column(modifier) {
|
||||
GroupedSurface(position = position) {
|
||||
Column(Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp)) {
|
||||
Text(
|
||||
label,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
InlineTextField(
|
||||
value = value,
|
||||
onValueChange = onValueChange,
|
||||
placeholder = placeholder,
|
||||
keyboardType = keyboardType,
|
||||
// A server address and a password are both case-sensitive,
|
||||
// and sentence-casing either is a support ticket.
|
||||
capitalization = KeyboardCapitalization.None,
|
||||
imeAction = ImeAction.Go,
|
||||
onImeAction = onImeAction,
|
||||
visualTransformation = if (masked) {
|
||||
PasswordVisualTransformation()
|
||||
} else {
|
||||
VisualTransformation.None
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
// ⚠️ Outside the surface, not inside it. An error rendered within the
|
||||
// field's own card reads as part of the value the user typed.
|
||||
(error ?: hint)?.let {
|
||||
Text(
|
||||
it,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = if (error != null) {
|
||||
MaterialTheme.colorScheme.error
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
},
|
||||
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 6.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The shell's primary action shape, so every step ends identically. */
|
||||
@Composable
|
||||
internal fun PrimaryAction(label: String, enabled: Boolean, onClick: () -> Unit) {
|
||||
Button(
|
||||
onClick = onClick,
|
||||
enabled = enabled,
|
||||
modifier = Modifier.fillMaxWidth().height(56.dp),
|
||||
) {
|
||||
Text(label, style = MaterialTheme.typography.titleMedium)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* What the chosen service requires, before it is required.
|
||||
*
|
||||
* ⚠️ Steps, not a paragraph. Fastmail and iCloud both reject the account
|
||||
* password with a plain 401, and the fix is a four-action errand in someone
|
||||
* else's web app — the exact shape of instruction that gets skimmed and missed
|
||||
* when it is written as prose. Google is the odd one out and stays a sentence:
|
||||
* there is no procedure, because there is nothing the user can do.
|
||||
*
|
||||
* ⚠️ Collapsed. By the time this draws, the errand has had a whole screen of its
|
||||
* own — [SetupStep] — so what is left to do here is let someone re-read it
|
||||
* without going back, not print it a second time under the field they are
|
||||
* trying to fill in.
|
||||
*/
|
||||
@Composable
|
||||
internal fun QuirkGuidance(quirk: ServerQuirk) {
|
||||
when (quirk) {
|
||||
ServerQuirk.FASTMAIL_APP_PASSWORD -> InstructionSteps(
|
||||
title = stringResource(R.string.add_account_setup_fastmail_title),
|
||||
steps = stringArrayResource(R.array.add_account_setup_fastmail_steps).asList(),
|
||||
footnote = stringResource(R.string.add_account_setup_fastmail_footnote),
|
||||
collapsible = true,
|
||||
)
|
||||
|
||||
ServerQuirk.ICLOUD_APP_SPECIFIC_PASSWORD -> InstructionSteps(
|
||||
title = stringResource(R.string.add_account_setup_icloud_title),
|
||||
steps = stringArrayResource(R.array.add_account_setup_icloud_steps).asList(),
|
||||
footnote = stringResource(R.string.add_account_setup_icloud_footnote),
|
||||
collapsible = true,
|
||||
)
|
||||
|
||||
ServerQuirk.GOOGLE_UNSUPPORTED ->
|
||||
QuirkNote(stringResource(R.string.add_account_quirk_google))
|
||||
|
||||
// A pre-flight warning for the engine, never something to read.
|
||||
ServerQuirk.NEXTCLOUD_BRUTE_FORCE_PROTECTED -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A single fact the user has to read — one that is not a procedure, so it is a
|
||||
* card rather than an [InstructionSteps] list of one.
|
||||
*
|
||||
* ⚠️ Inset to `GroupedListInset`, like a grouped row. Without it the note ran to
|
||||
* the screen edge while every row above it stopped 16dp short, so a caller that
|
||||
* is already inside a padded column must not add its own — see [BrowserStep].
|
||||
*/
|
||||
@Composable
|
||||
internal fun QuirkNote(text: String) {
|
||||
GroupedSurface(
|
||||
position = Position.Alone,
|
||||
modifier = Modifier.padding(horizontal = GroupedListInset),
|
||||
// Not tertiaryContainer: under dynamic colour that is the low-chroma
|
||||
// role, and against a light wallpaper-derived surface the card's own
|
||||
// edge disappears even though its text pairing is fine.
|
||||
color = MaterialTheme.colorScheme.secondaryContainer,
|
||||
gapBelow = false,
|
||||
) {
|
||||
Text(
|
||||
text,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||
modifier = Modifier.padding(GroupedListInset),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,460 +0,0 @@
|
||||
package de.jeanlucmakiola.agendula.ui.accounts.add
|
||||
|
||||
import android.content.Intent
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.browser.customtabs.CustomTabsIntent
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
|
||||
import androidx.compose.material.icons.rounded.Checklist
|
||||
import androidx.compose.material.icons.rounded.CloudOff
|
||||
import androidx.compose.material.icons.rounded.CloudSync
|
||||
import androidx.compose.material.icons.rounded.Lock
|
||||
import androidx.compose.material.icons.rounded.OpenInBrowser
|
||||
import androidx.compose.material.icons.rounded.Warning
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.net.toUri
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import de.jeanlucmakiola.agendula.R
|
||||
import de.jeanlucmakiola.agendula.ui.accounts.ProviderLogo
|
||||
import de.jeanlucmakiola.floret.components.OnboardingProgress
|
||||
import de.jeanlucmakiola.floret.components.OnboardingScaffold
|
||||
import de.jeanlucmakiola.floret.components.OnboardingSpace
|
||||
|
||||
/**
|
||||
* Adding a CalDAV account: one flow, one back-stack entry.
|
||||
*
|
||||
* A stepper rather than several destinations, because the steps are not
|
||||
* independently reachable — you cannot pick lists before signing in, and going
|
||||
* "back" from the browser step means abandoning a server-side flow rather than
|
||||
* popping a screen. The steps that *have* written nothing do step back, in the
|
||||
* flow rather than out of it; [AddAccountViewModel.onBackWithin] decides which,
|
||||
* and the shell falls out only when it says there is nowhere left to go. The
|
||||
* back arrow and the system back gesture are the same action and run the same
|
||||
* code — a stepper whose gesture skips the steps is a stepper with one way in
|
||||
* and no way back.
|
||||
*
|
||||
* [stepOffset] and [totalSteps] let a longer flow host this one inline — first
|
||||
* run offers the account before it has finished onboarding — so the wizard's
|
||||
* four steps report their place in the *outer* progress rather than restarting
|
||||
* the count at one. Left at their defaults it stands alone.
|
||||
*/
|
||||
@Composable
|
||||
internal fun AddAccountScreen(
|
||||
onDone: () -> Unit,
|
||||
onBack: () -> Unit,
|
||||
stepOffset: Int = 0,
|
||||
// Null when the wizard stands alone: it then counts its own steps, which
|
||||
// vary by provider. A host that draws one bar for a longer flow passes the
|
||||
// whole denominator, having already asked the wizard how long it is.
|
||||
totalSteps: Int? = null,
|
||||
viewModel: AddAccountViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
val context = LocalContext.current
|
||||
|
||||
LaunchedEffect(state.step) {
|
||||
if (state.step !is AddAccountStep.Done) return@LaunchedEffect
|
||||
onDone()
|
||||
// The ViewModel is scoped to the Settings back-stack entry and survives
|
||||
// this section being hidden, so a finished flow left at Done would bounce
|
||||
// the next "Add account" straight back out — and would reuse this
|
||||
// account's username and app password for the next one.
|
||||
viewModel.onStartOver()
|
||||
}
|
||||
|
||||
// Custom Tabs needs three things beyond launchUrl: the <queries> entry in the
|
||||
// manifest (or provider detection silently finds nothing on API 30+), a
|
||||
// fallback for devices with no Custom Tabs browser at all — realistic on
|
||||
// GrapheneOS, CalyxOS and plain AOSP, which is disproportionately this app's
|
||||
// audience — and an explicit way back, since a dismissed tab returns nothing.
|
||||
LaunchedEffect(state.openInBrowser) {
|
||||
val url = state.openInBrowser ?: return@LaunchedEffect
|
||||
val uri = url.toString().toUri()
|
||||
// ⚠️ Neither failure may escape, and neither may be ignored. An
|
||||
// exception out of a LaunchedEffect takes the app down — and the outer
|
||||
// catch named only ActivityNotFoundException, so a SecurityException
|
||||
// from a locked-down profile did exactly that. Reporting the failure is
|
||||
// the other half: clearing openInBrowser regardless left the user on
|
||||
// "waiting for your browser" with a spinner and no browser, on the very
|
||||
// devices this fallback exists for.
|
||||
val launched = runCatching {
|
||||
CustomTabsIntent.Builder().build().launchUrl(context, uri)
|
||||
}.recoverCatching {
|
||||
context.startActivity(Intent(Intent.ACTION_VIEW, uri))
|
||||
}.isSuccess
|
||||
if (launched) viewModel.onBrowserLaunched() else viewModel.onBrowserUnavailable()
|
||||
}
|
||||
|
||||
// Backing out abandons a flow that may still be polling the server every two
|
||||
// seconds for the rest of its twenty-minute window.
|
||||
val abandon = {
|
||||
viewModel.onStartOver()
|
||||
onBack()
|
||||
}
|
||||
|
||||
// One action, two ways to ask for it: step back inside the flow, and fall
|
||||
// out of it only when there is nowhere left to go.
|
||||
val goBack = { if (!viewModel.onBackWithin()) abandon() }
|
||||
|
||||
// ⚠️ The system gesture has to be caught here, not left to the host. The
|
||||
// hosts only put the section away — `SettingsScreen` sets `section = parent`
|
||||
// — while this ViewModel is scoped to the Settings back-stack entry and
|
||||
// outlives that. So a gesture that fell through left the flow *loaded*:
|
||||
// reopening "Add account" landed back on the previous server's list picker,
|
||||
// still holding its username, password and collections. Worse in
|
||||
// `WaitingForBrowser`, where the two-second poll kept running with no screen
|
||||
// attached for the rest of the twenty-minute window, and a password minted
|
||||
// after that point was held unrevoked until Settings itself was left.
|
||||
// Consuming it means [AddAccountViewModel.onStartOver] runs on every exit.
|
||||
BackHandler(onBack = goBack)
|
||||
|
||||
OnboardingScaffold(
|
||||
hero = { StepHero(state) },
|
||||
// Grouped rows and fields carry their own inset, so the column adds none.
|
||||
contentPadding = 0.dp,
|
||||
topSpacing = OnboardingSpace.md,
|
||||
progress = {
|
||||
val position = state.step.position(state.hasSetupStep)?.plus(stepOffset)
|
||||
val total = totalSteps ?: state.totalSteps
|
||||
if (position != null) {
|
||||
OnboardingProgress(
|
||||
step = position,
|
||||
total = total,
|
||||
label = stringResource(R.string.add_account_step_of, position, total),
|
||||
)
|
||||
}
|
||||
},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = goBack) {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Rounded.ArrowBack,
|
||||
contentDescription = stringResource(R.string.back),
|
||||
)
|
||||
}
|
||||
},
|
||||
actions = { StepActions(state, viewModel) },
|
||||
) {
|
||||
val fatal = state.fatal
|
||||
if (fatal != null) {
|
||||
Text(
|
||||
fatal.text(),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(horizontal = 24.dp),
|
||||
)
|
||||
return@OnboardingScaffold
|
||||
}
|
||||
|
||||
StepTitle(state.step)
|
||||
|
||||
when (val step = state.step) {
|
||||
is AddAccountStep.ChooseProvider -> ProviderStep(step, viewModel)
|
||||
is AddAccountStep.PrepareAccess -> SetupStep(step)
|
||||
is AddAccountStep.EnterAddress -> AddressStep(step, viewModel)
|
||||
is AddAccountStep.Working -> WorkingStep(step)
|
||||
is AddAccountStep.EnterCredentials -> CredentialsStep(step, viewModel)
|
||||
is AddAccountStep.ConfirmBrowser -> ConfirmBrowserStep(step)
|
||||
is AddAccountStep.WaitingForBrowser -> BrowserStep(step)
|
||||
is AddAccountStep.ChooseLists -> ListsStep(step, viewModel)
|
||||
is AddAccountStep.Summary -> SummaryStep(step)
|
||||
AddAccountStep.Done -> Unit
|
||||
}
|
||||
|
||||
// What is known about the chosen service, named before the attempt rather
|
||||
// than after a 401 the user cannot act on.
|
||||
//
|
||||
// ⚠️ Not on the errand step — that step *is* this, and drawing it again
|
||||
// underneath would print the same instructions twice. On the address step
|
||||
// it is the fallback for the one route that skips the errand: picking
|
||||
// "Other server" and then typing an address that turns out to be a
|
||||
// Fastmail or iCloud one.
|
||||
val note = when (state.step) {
|
||||
is AddAccountStep.ChooseProvider, is AddAccountStep.EnterAddress -> state.quirk
|
||||
else -> null
|
||||
}
|
||||
note?.let {
|
||||
Spacer(Modifier.height(OnboardingSpace.md))
|
||||
QuirkGuidance(it)
|
||||
}
|
||||
|
||||
// Learned during the browser step and shown from there on, whichever
|
||||
// step follows: the address it blames is one the user has to go and
|
||||
// correct on the server, and it is just as true once the lists load.
|
||||
state.originMismatch?.let { mismatch ->
|
||||
QuirkNote(
|
||||
text = stringResource(
|
||||
R.string.add_account_origin_mismatch,
|
||||
mismatch.actual,
|
||||
mismatch.expected,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Which of the flow's visible steps this is, or null for one that has no place.
|
||||
*
|
||||
* [hasSetup] shifts everything after the errand down by one, because the errand
|
||||
* is a step of its own rather than a screen borrowing the address's number.
|
||||
*/
|
||||
private fun AddAccountStep.position(hasSetup: Boolean): Int? {
|
||||
val errand = if (hasSetup) 1 else 0
|
||||
return when (this) {
|
||||
is AddAccountStep.ChooseProvider -> 1
|
||||
is AddAccountStep.PrepareAccess -> 2
|
||||
is AddAccountStep.EnterAddress -> 2 + errand
|
||||
// One slot, because only one of the three ever happens: a server either
|
||||
// hands the sign-in to a browser — asking first, where the URL needs
|
||||
// confirming — or asks for a password.
|
||||
is AddAccountStep.EnterCredentials,
|
||||
is AddAccountStep.ConfirmBrowser,
|
||||
is AddAccountStep.WaitingForBrowser,
|
||||
-> 3 + errand
|
||||
is AddAccountStep.ChooseLists -> 4 + errand
|
||||
// The receipt keeps the bar full rather than dropping it: the flow is
|
||||
// finished, and a chrome that vanishes on the last screen reads as a
|
||||
// step lost rather than a step done.
|
||||
is AddAccountStep.Summary -> ADD_ACCOUNT_STEPS + errand
|
||||
// Working is a moment inside whichever step spawned it, and Done is gone
|
||||
// before it draws — neither is a place the user can be.
|
||||
is AddAccountStep.Working, AddAccountStep.Done -> null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The mark for the step, in the family's tonal circle.
|
||||
*
|
||||
* Once a service has been chosen it wears *that service's* logo instead — the
|
||||
* same mark the accounts list will show — so the flow keeps saying what is being
|
||||
* set up rather than restating that an account is being added.
|
||||
*/
|
||||
@Composable
|
||||
private fun StepHero(state: AddAccountUiState) {
|
||||
if (state.step is AddAccountStep.Summary) {
|
||||
ProviderLogo(state.step.provider, size = 72.dp)
|
||||
return
|
||||
}
|
||||
val chosen = (state.step as? AddAccountStep.EnterAddress)?.choice?.provider
|
||||
?: (state.step as? AddAccountStep.PrepareAccess)?.choice?.provider
|
||||
if (chosen != null && state.fatal == null) {
|
||||
ProviderLogo(chosen, size = 72.dp)
|
||||
return
|
||||
}
|
||||
val icon = when {
|
||||
state.fatal != null -> Icons.Rounded.CloudOff
|
||||
state.step is AddAccountStep.EnterCredentials -> Icons.Rounded.Lock
|
||||
// The warning is the screen, so it is the mark too — the browser icon
|
||||
// would say "this is going fine", which is the opposite of the point.
|
||||
state.step is AddAccountStep.ConfirmBrowser -> Icons.Rounded.Warning
|
||||
state.step is AddAccountStep.WaitingForBrowser -> Icons.Rounded.OpenInBrowser
|
||||
state.step is AddAccountStep.ChooseLists -> Icons.Rounded.Checklist
|
||||
else -> Icons.Rounded.CloudSync
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(72.dp)
|
||||
.clip(CircleShape)
|
||||
.background(MaterialTheme.colorScheme.secondaryContainer),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
icon,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||
modifier = Modifier.size(34.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Title and one line of explanation, centred above whatever the step asks for. */
|
||||
@Composable
|
||||
private fun StepTitle(step: AddAccountStep) {
|
||||
val (title, body) = when (step) {
|
||||
is AddAccountStep.ChooseProvider ->
|
||||
R.string.add_account_provider_title to R.string.add_account_provider_body
|
||||
is AddAccountStep.PrepareAccess ->
|
||||
step.quirk.setupTitle to R.string.add_account_setup_body
|
||||
is AddAccountStep.EnterAddress -> if (step.choice.provider?.hosted == true) {
|
||||
R.string.add_account_email_title to R.string.add_account_email_body
|
||||
} else {
|
||||
R.string.add_account_server_title to R.string.add_account_server_body
|
||||
}
|
||||
is AddAccountStep.EnterCredentials ->
|
||||
R.string.add_account_credentials_title to R.string.add_account_credentials_body
|
||||
is AddAccountStep.ConfirmBrowser ->
|
||||
R.string.add_account_browser_confirm_title to R.string.add_account_browser_confirm_body
|
||||
is AddAccountStep.ChooseLists ->
|
||||
R.string.add_account_lists_title to R.string.add_account_lists_body
|
||||
is AddAccountStep.Summary ->
|
||||
R.string.add_account_summary_title to R.string.add_account_summary_body
|
||||
else -> return
|
||||
}
|
||||
Text(
|
||||
stringResource(title),
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(horizontal = 24.dp),
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
stringResource(body),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(horizontal = 24.dp),
|
||||
)
|
||||
Spacer(Modifier.height(OnboardingSpace.lg))
|
||||
}
|
||||
|
||||
/**
|
||||
* The step's call to action, pinned at the bottom the way the onboarding shell
|
||||
* puts it.
|
||||
*
|
||||
* One primary action per step, always in the same place, so the flow reads as
|
||||
* one screen advancing rather than five different ones. A step that commits
|
||||
* nothing renders no button rather than a disabled one.
|
||||
*/
|
||||
@Composable
|
||||
private fun ColumnScope.StepActions(state: AddAccountUiState, viewModel: AddAccountViewModel) {
|
||||
if (state.fatal != null) {
|
||||
PrimaryAction(
|
||||
label = stringResource(R.string.add_account_start_over),
|
||||
enabled = true,
|
||||
onClick = viewModel::onStartOver,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
when (val step = state.step) {
|
||||
// ⚠️ No action at all. Tapping a row *is* the choice and advances on the
|
||||
// spot, so a Continue here would be a second press for the same decision
|
||||
// — and a disabled one, before anything is picked, that looks like the
|
||||
// screen is broken.
|
||||
is AddAccountStep.ChooseProvider -> Unit
|
||||
|
||||
is AddAccountStep.PrepareAccess -> PrimaryAction(
|
||||
label = stringResource(R.string.add_account_continue),
|
||||
enabled = true,
|
||||
onClick = viewModel::onSetupAcknowledged,
|
||||
)
|
||||
|
||||
is AddAccountStep.EnterAddress -> PrimaryAction(
|
||||
label = stringResource(R.string.add_account_continue),
|
||||
enabled = step.input.isNotBlank(),
|
||||
onClick = viewModel::onAddressSubmitted,
|
||||
)
|
||||
|
||||
is AddAccountStep.EnterCredentials -> PrimaryAction(
|
||||
label = stringResource(R.string.add_account_sign_in),
|
||||
enabled = step.username.isNotBlank() && step.password.isNotEmpty(),
|
||||
onClick = viewModel::onCredentialsSubmitted,
|
||||
)
|
||||
|
||||
is AddAccountStep.ChooseLists -> PrimaryAction(
|
||||
// The count is the feedback: a disabled button with no explanation
|
||||
// is the commonest way a picker looks broken.
|
||||
label = if (step.selected.isEmpty()) {
|
||||
stringResource(R.string.add_account_no_lists_selected)
|
||||
} else {
|
||||
stringResource(R.string.add_account_save)
|
||||
},
|
||||
enabled = step.selected.isNotEmpty(),
|
||||
onClick = viewModel::onSave,
|
||||
)
|
||||
|
||||
is AddAccountStep.Summary -> PrimaryAction(
|
||||
label = stringResource(R.string.add_account_summary_done),
|
||||
enabled = true,
|
||||
onClick = viewModel::onSummaryDone,
|
||||
)
|
||||
|
||||
// The way forward stays primary: both causes are legitimate behind a
|
||||
// reverse proxy, and refusing outright would make Login Flow v2 unusable
|
||||
// for a large share of self-hosted installs. The password route is the
|
||||
// secondary, exactly as it is one step later.
|
||||
is AddAccountStep.ConfirmBrowser -> {
|
||||
PrimaryAction(
|
||||
label = stringResource(R.string.add_account_browser_open),
|
||||
enabled = true,
|
||||
onClick = viewModel::onBrowserConfirmed,
|
||||
)
|
||||
TextButton(
|
||||
onClick = viewModel::onBrowserCancelled,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) { Text(stringResource(R.string.add_account_browser_use_password)) }
|
||||
}
|
||||
|
||||
is AddAccountStep.WaitingForBrowser -> {
|
||||
// A failed flow is retried by default. With no browser a retry
|
||||
// cannot help; while the server throttles or is in maintenance an
|
||||
// immediate one only adds to it, so a password comes first.
|
||||
val usePassword = @Composable { primary: Boolean ->
|
||||
if (primary) {
|
||||
PrimaryAction(
|
||||
label = stringResource(R.string.add_account_browser_use_password),
|
||||
enabled = true,
|
||||
onClick = viewModel::onBrowserCancelled,
|
||||
)
|
||||
} else {
|
||||
TextButton(onClick = viewModel::onBrowserCancelled, modifier = Modifier.fillMaxWidth()) {
|
||||
Text(stringResource(R.string.add_account_browser_use_password))
|
||||
}
|
||||
}
|
||||
}
|
||||
val retry = @Composable { primary: Boolean ->
|
||||
if (primary) {
|
||||
PrimaryAction(
|
||||
label = stringResource(R.string.add_account_browser_retry),
|
||||
enabled = true,
|
||||
onClick = viewModel::onBrowserRetry,
|
||||
)
|
||||
} else {
|
||||
TextButton(onClick = viewModel::onBrowserRetry, modifier = Modifier.fillMaxWidth()) {
|
||||
Text(stringResource(R.string.add_account_browser_retry))
|
||||
}
|
||||
}
|
||||
}
|
||||
when (step.error) {
|
||||
null -> usePassword(false)
|
||||
AddAccountMessage.BrowserUnavailable -> usePassword(true)
|
||||
AddAccountMessage.BrowserRateLimited, AddAccountMessage.BrowserMaintenance -> {
|
||||
usePassword(true)
|
||||
retry(false)
|
||||
}
|
||||
else -> {
|
||||
retry(true)
|
||||
usePassword(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
is AddAccountStep.Working, AddAccountStep.Done -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
package de.jeanlucmakiola.agendula.ui.accounts.add
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import de.jeanlucmakiola.agendula.R
|
||||
import de.jeanlucmakiola.caldav.CalDavDiscovery
|
||||
import de.jeanlucmakiola.caldav.ServerQuirk
|
||||
|
||||
/**
|
||||
* A discovery failure, in words rather than a status code.
|
||||
*
|
||||
* ⚠️ The server's own message never reaches the screen. The most common failure
|
||||
* here is a **405** — what an ordinary web server answers to `PROPFIND`, and so
|
||||
* what someone typing their *website* instead of their CalDAV address gets — and
|
||||
* "HTTP 405 Method Not Allowed" tells them nothing they can act on. It is also
|
||||
* untranslatable, and frequently not in a language they read.
|
||||
*/
|
||||
internal val CalDavDiscovery.Outcome.Cause.message: Int
|
||||
get() = when (this) {
|
||||
CalDavDiscovery.Outcome.Cause.NOT_AN_ADDRESS -> R.string.add_account_error_not_an_address
|
||||
CalDavDiscovery.Outcome.Cause.NOT_A_DAV_SERVER -> R.string.add_account_error_not_dav
|
||||
CalDavDiscovery.Outcome.Cause.NO_CALENDAR_SUPPORT -> R.string.add_account_error_no_calendar
|
||||
CalDavDiscovery.Outcome.Cause.UNREACHABLE -> R.string.add_account_error_unreachable
|
||||
CalDavDiscovery.Outcome.Cause.INSECURE -> R.string.add_account_error_insecure
|
||||
CalDavDiscovery.Outcome.Cause.NO_CALENDARS -> R.string.add_account_error_no_calendars
|
||||
CalDavDiscovery.Outcome.Cause.SERVER_ERROR -> R.string.add_account_error_server
|
||||
}
|
||||
|
||||
/**
|
||||
* The same rule as [message], for the messages the flow itself produces.
|
||||
*
|
||||
* Kept here rather than on [AddAccountMessage] so the type stays a plain Kotlin
|
||||
* one and the resource ids stay where the resources are.
|
||||
*/
|
||||
@Composable
|
||||
internal fun AddAccountMessage.text(): String = when (this) {
|
||||
AddAccountMessage.Progress.Discovering ->
|
||||
stringResource(R.string.add_account_working_discovering)
|
||||
AddAccountMessage.Progress.SigningIn ->
|
||||
stringResource(R.string.add_account_working_signing_in)
|
||||
AddAccountMessage.Progress.ReadingLists ->
|
||||
stringResource(R.string.add_account_working_reading_lists)
|
||||
AddAccountMessage.Progress.Saving ->
|
||||
stringResource(R.string.add_account_working_saving)
|
||||
AddAccountMessage.GoogleUnsupported ->
|
||||
stringResource(R.string.add_account_error_google_unsupported)
|
||||
AddAccountMessage.AlreadyExists ->
|
||||
stringResource(R.string.add_account_error_already_exists)
|
||||
AddAccountMessage.ExternalStorage ->
|
||||
stringResource(R.string.accounts_external_storage)
|
||||
AddAccountMessage.NoUsableLists ->
|
||||
stringResource(R.string.add_account_error_no_usable_lists)
|
||||
AddAccountMessage.NotSaved -> stringResource(R.string.add_account_error_not_saved)
|
||||
AddAccountMessage.KeystoreRefused -> stringResource(R.string.add_account_error_keystore)
|
||||
AddAccountMessage.CredentialsRejected ->
|
||||
stringResource(R.string.add_account_error_credentials_rejected)
|
||||
AddAccountMessage.BrowserApprovalExpired ->
|
||||
stringResource(R.string.add_account_browser_error_expired)
|
||||
AddAccountMessage.BrowserRateLimited ->
|
||||
stringResource(R.string.add_account_browser_error_rate_limited)
|
||||
AddAccountMessage.BrowserMaintenance ->
|
||||
stringResource(R.string.add_account_browser_error_maintenance)
|
||||
AddAccountMessage.BrowserFailed ->
|
||||
stringResource(R.string.add_account_browser_error_failed)
|
||||
AddAccountMessage.BrowserUnavailable ->
|
||||
stringResource(R.string.add_account_browser_error_unavailable)
|
||||
is AddAccountMessage.OutsideCredentialScope ->
|
||||
stringResource(R.string.add_account_error_cross_domain, host)
|
||||
is AddAccountMessage.Quirk -> when (quirk) {
|
||||
ServerQuirk.FASTMAIL_APP_PASSWORD ->
|
||||
stringResource(R.string.add_account_quirk_hint_fastmail)
|
||||
ServerQuirk.ICLOUD_APP_SPECIFIC_PASSWORD ->
|
||||
stringResource(R.string.add_account_quirk_hint_icloud)
|
||||
// Neither reaches a credentials step: Google is refused before it, and
|
||||
// the brute-force note is a pre-flight warning.
|
||||
else -> ""
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,43 +0,0 @@
|
||||
package de.jeanlucmakiola.agendula.ui.accounts.add
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.dp
|
||||
import de.jeanlucmakiola.agendula.R
|
||||
|
||||
/**
|
||||
* Step 2: the one address the chosen service actually needs.
|
||||
*
|
||||
* A hosted service is reached by the email address the user already knows, so it
|
||||
* asks for that and nothing else; a server they run is reached by a URL only
|
||||
* they have. The old single field had to ask for either, which is why its hint
|
||||
* had to show both.
|
||||
*/
|
||||
@Composable
|
||||
internal fun AddressStep(step: AddAccountStep.EnterAddress, viewModel: AddAccountViewModel) {
|
||||
val hosted = step.choice.provider?.hosted == true
|
||||
Column(Modifier.padding(horizontal = 16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||
FieldRow(
|
||||
label = if (hosted) {
|
||||
stringResource(R.string.add_account_email_label)
|
||||
} else {
|
||||
stringResource(R.string.add_account_server_label)
|
||||
},
|
||||
value = step.input,
|
||||
onValueChange = viewModel::onAddressChanged,
|
||||
placeholder = step.choice.provider?.primaryDomain?.let { "you@$it" }
|
||||
?: stringResource(R.string.add_account_server_hint),
|
||||
error = step.error?.let { stringResource(it.message) },
|
||||
// An email address is still typed with the URI keyboard: it is the
|
||||
// one that carries "@" and "." without a shift, and the field takes
|
||||
// a URL too whenever a hosted service is reached by one.
|
||||
keyboardType = KeyboardType.Uri,
|
||||
onImeAction = viewModel::onAddressSubmitted,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
package de.jeanlucmakiola.agendula.ui.accounts.add
|
||||
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import de.jeanlucmakiola.agendula.R
|
||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||
import de.jeanlucmakiola.floret.components.SelectedCheck
|
||||
import de.jeanlucmakiola.floret.components.positionOf
|
||||
|
||||
/** Step 4: which of the account's collections to keep in sync. */
|
||||
@Composable
|
||||
internal fun ListsStep(step: AddAccountStep.ChooseLists, viewModel: AddAccountViewModel) {
|
||||
// The heading is the shell's; this step only lists.
|
||||
step.collections.forEachIndexed { index, collection ->
|
||||
val checked = collection.url in step.selected
|
||||
GroupedRow(
|
||||
title = collection.displayName ?: collection.url.encodedPath,
|
||||
summary = when {
|
||||
collection.readOnly -> stringResource(R.string.add_account_lists_read_only)
|
||||
collection.isShared -> stringResource(R.string.add_account_lists_shared)
|
||||
else -> null
|
||||
},
|
||||
position = positionOf(index, step.collections.size),
|
||||
selected = checked,
|
||||
// The family marks a chosen row with a check, not a Material
|
||||
// checkbox — same affordance every picker in the app uses.
|
||||
trailing = { if (checked) SelectedCheck() },
|
||||
onClick = { viewModel.onListToggled(collection.url) },
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(16.dp))
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
package de.jeanlucmakiola.agendula.ui.accounts.add
|
||||
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import de.jeanlucmakiola.agendula.R
|
||||
import de.jeanlucmakiola.agendula.ui.accounts.ProviderLogo
|
||||
import de.jeanlucmakiola.caldav.CalDavProvider
|
||||
import de.jeanlucmakiola.caldav.ServerQuirk
|
||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||
import de.jeanlucmakiola.floret.components.GroupedSectionHeader
|
||||
import de.jeanlucmakiola.floret.components.Position
|
||||
import de.jeanlucmakiola.floret.components.SelectedCheck
|
||||
import de.jeanlucmakiola.floret.components.positionOf
|
||||
|
||||
/**
|
||||
* Step 1: which service the tasks live on.
|
||||
*
|
||||
* The services we know by name, each by its own mark, and then the escape hatch
|
||||
* for everything else. Choosing here is what lets step 2 ask one clear question
|
||||
* instead of "an email address, or a server address, whichever you have" — and
|
||||
* it is what puts a provider's app-password rule in front of the user *before*
|
||||
* the 401 rather than after it.
|
||||
*/
|
||||
@Composable
|
||||
internal fun ProviderStep(step: AddAccountStep.ChooseProvider, viewModel: AddAccountViewModel) {
|
||||
val services = CalDavProvider.selectable
|
||||
services.forEachIndexed { index, provider ->
|
||||
val choice = ProviderChoice.Service(provider)
|
||||
val chosen = step.choice == choice
|
||||
// ⚠️ On the row, not behind a tap. Google is listed because people come
|
||||
// looking for it — an absent row reads as the app being unfinished
|
||||
// rather than as Google's own limitation — but a row that only reveals
|
||||
// why it cannot be used *after* being selected is the worst of both: it
|
||||
// looks available, then quietly refuses.
|
||||
val unusable = ServerQuirk.forProvider(provider)?.isFatal == true
|
||||
GroupedRow(
|
||||
title = provider.label,
|
||||
summary = when {
|
||||
unusable -> stringResource(R.string.add_account_provider_unsupported)
|
||||
else -> provider.primaryDomain
|
||||
?: stringResource(R.string.add_account_provider_self_hosted)
|
||||
},
|
||||
position = positionOf(index, services.size),
|
||||
selected = chosen && !unusable,
|
||||
dimmed = unusable,
|
||||
leading = { ProviderLogo(provider) },
|
||||
trailing = { if (chosen && !unusable) SelectedCheck() },
|
||||
// Still tappable: the summary is the short reason, and the note the
|
||||
// tap brings up is the long one.
|
||||
onClick = { viewModel.onProviderChosen(choice) },
|
||||
)
|
||||
}
|
||||
|
||||
GroupedSectionHeader(stringResource(R.string.add_account_provider_other_header))
|
||||
val other = ProviderChoice.OtherServer
|
||||
val otherChosen = step.choice == other
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.add_account_provider_other),
|
||||
summary = stringResource(R.string.add_account_provider_other_summary),
|
||||
position = Position.Alone,
|
||||
selected = otherChosen,
|
||||
// Null is the family's "a server, unnamed" mark — exactly what this row
|
||||
// is choosing.
|
||||
leading = { ProviderLogo(provider = null) },
|
||||
trailing = { if (otherChosen) SelectedCheck() },
|
||||
onClick = { viewModel.onProviderChosen(other) },
|
||||
)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
package de.jeanlucmakiola.agendula.ui.accounts.add
|
||||
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringArrayResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import de.jeanlucmakiola.agendula.R
|
||||
import de.jeanlucmakiola.caldav.ServerQuirk
|
||||
import de.jeanlucmakiola.floret.components.InstructionSteps
|
||||
|
||||
/**
|
||||
* Step 2, first half: the errand the chosen service requires, before anything is
|
||||
* typed.
|
||||
*
|
||||
* A screen of its own rather than a note under the address field. What it asks
|
||||
* for happens somewhere else entirely — open a browser, sign in to the provider,
|
||||
* mint an app password, come back — so it has to be read *before* the fields it
|
||||
* makes answerable, and it needs the room to be four numbered actions rather
|
||||
* than a paragraph. Underneath an input, it was neither.
|
||||
*
|
||||
* The list carries no header of its own: the step's headline already names the
|
||||
* requirement, and repeating it above the rows says it twice.
|
||||
*/
|
||||
@Composable
|
||||
internal fun SetupStep(step: AddAccountStep.PrepareAccess) {
|
||||
InstructionSteps(
|
||||
steps = stringArrayResource(step.quirk.steps).asList(),
|
||||
footnote = stringResource(step.quirk.footnote),
|
||||
)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
}
|
||||
|
||||
/** The headline for the errand — what the service requires, in one line. */
|
||||
internal val ServerQuirk.setupTitle: Int
|
||||
get() = when (this) {
|
||||
ServerQuirk.FASTMAIL_APP_PASSWORD -> R.string.add_account_setup_fastmail_title
|
||||
else -> R.string.add_account_setup_icloud_title
|
||||
}
|
||||
|
||||
private val ServerQuirk.steps: Int
|
||||
get() = when (this) {
|
||||
ServerQuirk.FASTMAIL_APP_PASSWORD -> R.array.add_account_setup_fastmail_steps
|
||||
// Only two quirks reach this screen — ServerQuirk.hasSetupSteps is what
|
||||
// decides — so the branch that cannot happen takes the other one rather
|
||||
// than inventing a third set of instructions to be wrong with.
|
||||
else -> R.array.add_account_setup_icloud_steps
|
||||
}
|
||||
|
||||
private val ServerQuirk.footnote: Int
|
||||
get() = when (this) {
|
||||
ServerQuirk.FASTMAIL_APP_PASSWORD -> R.string.add_account_setup_fastmail_footnote
|
||||
else -> R.string.add_account_setup_icloud_footnote
|
||||
}
|
||||
@@ -1,167 +0,0 @@
|
||||
package de.jeanlucmakiola.agendula.ui.accounts.add
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.rounded.CloudOff
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.dp
|
||||
import de.jeanlucmakiola.agendula.R
|
||||
import de.jeanlucmakiola.floret.components.Position
|
||||
|
||||
/**
|
||||
* Step 3a: a username and a password.
|
||||
*
|
||||
* The generic CalDAV route, taken whenever the server has no browser sign-in to
|
||||
* hand off to.
|
||||
*/
|
||||
@Composable
|
||||
internal fun CredentialsStep(
|
||||
step: AddAccountStep.EnterCredentials,
|
||||
viewModel: AddAccountViewModel,
|
||||
) {
|
||||
Column(Modifier.padding(horizontal = 16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||
// Two fields in one connected group, which is what the family does with
|
||||
// a pair that is filled in together.
|
||||
Column {
|
||||
FieldRow(
|
||||
label = stringResource(R.string.add_account_username_label),
|
||||
value = step.username,
|
||||
onValueChange = viewModel::onUsernameChanged,
|
||||
position = Position.Top,
|
||||
)
|
||||
FieldRow(
|
||||
label = stringResource(R.string.add_account_password_label),
|
||||
value = step.password,
|
||||
onValueChange = viewModel::onPasswordChanged,
|
||||
position = Position.Bottom,
|
||||
error = step.error?.text(),
|
||||
hint = stringResource(R.string.add_account_password_hint),
|
||||
keyboardType = KeyboardType.Password,
|
||||
onImeAction = viewModel::onCredentialsSubmitted,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Step 3b, first half: what is wrong with the page we are about to open.
|
||||
*
|
||||
* ⚠️ A screen of its own, drawn *before* the Custom Tab rather than behind it.
|
||||
* The login URL is where the account password is typed, so a warning about it
|
||||
* has to be readable at the moment it still means something — and the primary
|
||||
* action stays the way forward, because both causes are legitimate on real
|
||||
* deployments and refusing outright would make Login Flow v2 unusable behind an
|
||||
* ordinary reverse proxy.
|
||||
*/
|
||||
@Composable
|
||||
internal fun ColumnScope.ConfirmBrowserStep(step: AddAccountStep.ConfirmBrowser) {
|
||||
// Title and body come from StepTitle like every other fixed step's do; what
|
||||
// is left here is the cause, which is the whole reason the step exists.
|
||||
step.hostMismatch?.let { mismatch ->
|
||||
QuirkNote(
|
||||
text = stringResource(
|
||||
R.string.add_account_browser_host_mismatch,
|
||||
mismatch.actual,
|
||||
mismatch.expected,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// Both can be true at once — a proxy that rewrites the host and drops TLS
|
||||
// is one misconfiguration, not two — and each is worth its own sentence.
|
||||
if (step.insecure) {
|
||||
Spacer(Modifier.height(16.dp))
|
||||
QuirkNote(text = stringResource(R.string.add_account_browser_insecure))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Step 3b: the server is signing the user in, in a browser we do not own.
|
||||
*
|
||||
* The same slot as [CredentialsStep] because only one of the two ever happens —
|
||||
* but its own title, mark and body, since "approve this in your browser" and
|
||||
* "type your password" have nothing in common but their place in the flow.
|
||||
*/
|
||||
@Composable
|
||||
internal fun ColumnScope.BrowserStep(step: AddAccountStep.WaitingForBrowser) {
|
||||
// ⚠️ The title changes too. Swapping only the body left every failure in
|
||||
// this phase — a burnt credential, a 429, a maintenance page — sitting
|
||||
// under the heading "Waiting for your browser", which reads as "still
|
||||
// working" when nothing is working and nothing else will happen.
|
||||
Message(
|
||||
icon = {
|
||||
if (step.error == null) {
|
||||
CircularProgressIndicator(Modifier.size(24.dp))
|
||||
} else {
|
||||
Icon(Icons.Rounded.CloudOff, contentDescription = null)
|
||||
}
|
||||
},
|
||||
title = if (step.error == null) {
|
||||
stringResource(R.string.add_account_browser_title)
|
||||
} else {
|
||||
stringResource(R.string.add_account_browser_failed_title)
|
||||
},
|
||||
text = step.error?.text() ?: stringResource(R.string.add_account_browser_body),
|
||||
)
|
||||
|
||||
// ⚠️ A sibling of the message, not a child of its padded column: QuirkNote
|
||||
// carries the grouped-list inset itself, and nesting it inside one that
|
||||
// already pads by the same amount insets it twice.
|
||||
step.hostMismatch?.let { mismatch ->
|
||||
Spacer(Modifier.height(16.dp))
|
||||
QuirkNote(
|
||||
text = stringResource(
|
||||
R.string.add_account_browser_host_mismatch,
|
||||
mismatch.actual,
|
||||
mismatch.expected,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun WorkingStep(step: AddAccountStep.Working) {
|
||||
Column(
|
||||
Modifier.fillMaxWidth().padding(32.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
CircularProgressIndicator()
|
||||
Text(step.message.text(), style = MaterialTheme.typography.bodyLarge)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Message(
|
||||
icon: @Composable () -> Unit,
|
||||
text: String,
|
||||
title: String? = null,
|
||||
) {
|
||||
Column(
|
||||
Modifier.fillMaxWidth().padding(horizontal = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
icon()
|
||||
title?.let { Text(it, style = MaterialTheme.typography.titleMedium) }
|
||||
Text(
|
||||
text,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
package de.jeanlucmakiola.agendula.ui.accounts.add
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.rounded.Check
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import de.jeanlucmakiola.agendula.R
|
||||
import de.jeanlucmakiola.agendula.ui.accounts.ProviderLogo
|
||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||
import de.jeanlucmakiola.floret.components.GroupedSectionHeader
|
||||
import de.jeanlucmakiola.floret.components.positionOf
|
||||
|
||||
/**
|
||||
* The receipt: what the flow just set up, under the name the accounts list will
|
||||
* use for it.
|
||||
*
|
||||
* Not a confirmation — the account is already saved, and there is nothing here
|
||||
* to undo. It exists because the wizard used to vanish on success, leaving "did
|
||||
* it take the right lists, and under which name?" answerable only by going and
|
||||
* finding the account again.
|
||||
*/
|
||||
@Composable
|
||||
internal fun SummaryStep(step: AddAccountStep.Summary) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
ProviderLogo(step.provider, size = 48.dp)
|
||||
Spacer(Modifier.width(16.dp))
|
||||
Column {
|
||||
Text(step.title, style = MaterialTheme.typography.titleMedium)
|
||||
// The account's own two supporting facts, in the order the accounts
|
||||
// list gives them: what it is, then who you are on it.
|
||||
listOfNotNull(step.secondary, step.username.takeIf { it.isNotBlank() })
|
||||
.forEach {
|
||||
Text(
|
||||
it,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(24.dp))
|
||||
|
||||
GroupedSectionHeader(
|
||||
pluralStringResource(
|
||||
R.plurals.add_account_summary_lists,
|
||||
step.lists.size,
|
||||
step.lists.size,
|
||||
),
|
||||
)
|
||||
step.lists.forEachIndexed { index, name ->
|
||||
GroupedRow(
|
||||
title = name,
|
||||
position = positionOf(index, step.lists.size),
|
||||
leading = {
|
||||
Icon(
|
||||
Icons.Rounded.Check,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(16.dp))
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import androidx.compose.material.icons.rounded.LocationOn
|
||||
import androidx.compose.material.icons.rounded.Notifications
|
||||
import androidx.compose.material.icons.rounded.Percent
|
||||
import androidx.compose.material.icons.rounded.Repeat
|
||||
import androidx.compose.material.icons.rounded.Sell
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import de.jeanlucmakiola.agendula.R
|
||||
import de.jeanlucmakiola.agendula.domain.TaskFormField
|
||||
@@ -23,6 +24,7 @@ fun TaskFormField.labelRes(): Int = when (this) {
|
||||
TaskFormField.Reminder -> R.string.edit_reminder_label
|
||||
TaskFormField.Location -> R.string.edit_location_label
|
||||
TaskFormField.Url -> R.string.edit_url_label
|
||||
TaskFormField.Tags -> R.string.edit_tags_label
|
||||
}
|
||||
|
||||
fun TaskFormField.icon(): ImageVector = when (this) {
|
||||
@@ -34,4 +36,5 @@ fun TaskFormField.icon(): ImageVector = when (this) {
|
||||
TaskFormField.Reminder -> Icons.Rounded.Notifications
|
||||
TaskFormField.Location -> Icons.Rounded.LocationOn
|
||||
TaskFormField.Url -> Icons.Rounded.Link
|
||||
TaskFormField.Tags -> Icons.Rounded.Sell
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package de.jeanlucmakiola.agendula.ui.common
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.unit.dp
|
||||
import de.jeanlucmakiola.floret.components.GroupedListInset
|
||||
import de.jeanlucmakiola.floret.components.GroupedSurface
|
||||
import de.jeanlucmakiola.floret.components.InlineTextField
|
||||
import de.jeanlucmakiola.floret.components.Position
|
||||
|
||||
/** A lone grouped card holding a name, for the list and filter editors. */
|
||||
@Composable
|
||||
internal fun NameField(
|
||||
name: String,
|
||||
placeholder: String,
|
||||
autoFocus: Boolean,
|
||||
onNameChange: (String) -> Unit,
|
||||
onImeAction: () -> Unit,
|
||||
leading: (@Composable () -> Unit)? = null,
|
||||
) {
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
LaunchedEffect(autoFocus) { if (autoFocus) focusRequester.requestFocus() }
|
||||
GroupedSurface(position = Position.Alone, modifier = Modifier.padding(horizontal = GroupedListInset)) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().heightIn(min = 72.dp).padding(horizontal = 16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
leading?.invoke()
|
||||
InlineTextField(
|
||||
value = name,
|
||||
onValueChange = onNameChange,
|
||||
placeholder = placeholder,
|
||||
imeAction = ImeAction.Done,
|
||||
onImeAction = onImeAction,
|
||||
modifier = Modifier.fillMaxWidth().focusRequester(focusRequester),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package de.jeanlucmakiola.agendula.ui.common
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
/**
|
||||
* A pill naming one tag. Solid secondary by default so it reads against any card;
|
||||
* [muted] is the quieter tonal look for something not yet applied, like a suggestion.
|
||||
*/
|
||||
@Composable
|
||||
fun TagChip(
|
||||
name: String,
|
||||
modifier: Modifier = Modifier,
|
||||
muted: Boolean = false,
|
||||
icon: ImageVector? = null,
|
||||
iconContentDescription: String? = null,
|
||||
onClick: (() -> Unit)? = null,
|
||||
) {
|
||||
val container = if (muted) MaterialTheme.colorScheme.secondaryContainer else MaterialTheme.colorScheme.secondary
|
||||
val content = if (muted) MaterialTheme.colorScheme.onSecondaryContainer else MaterialTheme.colorScheme.onSecondary
|
||||
val shape = RoundedCornerShape(50)
|
||||
val body: @Composable () -> Unit = {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
modifier = Modifier.padding(start = 12.dp, end = if (icon != null) 8.dp else 12.dp, top = 6.dp, bottom = 6.dp),
|
||||
) {
|
||||
Text(
|
||||
text = name,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
if (icon != null) {
|
||||
Icon(icon, contentDescription = iconContentDescription, modifier = Modifier.size(16.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
if (onClick != null) {
|
||||
Surface(onClick = onClick, color = container, contentColor = content, shape = shape, modifier = modifier) { body() }
|
||||
} else {
|
||||
Surface(color = container, contentColor = content, shape = shape, modifier = modifier) { body() }
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,8 @@ import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
@@ -42,6 +44,7 @@ import android.net.Uri
|
||||
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||
import androidx.compose.material.icons.rounded.Info
|
||||
import androidx.compose.material.icons.rounded.Link
|
||||
import androidx.compose.material.icons.rounded.Sell
|
||||
import androidx.compose.material.icons.rounded.LocationOn
|
||||
import androidx.compose.material.icons.rounded.Notifications
|
||||
import androidx.compose.material.icons.rounded.Repeat
|
||||
@@ -60,6 +63,7 @@ import de.jeanlucmakiola.agendula.domain.TaskStatus
|
||||
import de.jeanlucmakiola.agendula.domain.recurrence.RecurringScope
|
||||
import de.jeanlucmakiola.agendula.ui.common.LocalUse24HourFormat
|
||||
import de.jeanlucmakiola.agendula.ui.common.RecurringScopeDialog
|
||||
import de.jeanlucmakiola.agendula.ui.common.TagChip
|
||||
import de.jeanlucmakiola.agendula.ui.common.linkified
|
||||
import de.jeanlucmakiola.agendula.ui.common.recurrenceText
|
||||
import de.jeanlucmakiola.agendula.ui.common.reminderLeadTimeLabel
|
||||
@@ -97,6 +101,7 @@ import de.jeanlucmakiola.agendula.R
|
||||
import de.jeanlucmakiola.agendula.domain.readableDescription
|
||||
import de.jeanlucmakiola.agendula.domain.Task
|
||||
import de.jeanlucmakiola.agendula.domain.TaskDetail
|
||||
import de.jeanlucmakiola.floret.components.revealOnFocus
|
||||
import de.jeanlucmakiola.floret.components.GroupedSurface
|
||||
import de.jeanlucmakiola.agendula.ui.common.PriorityChip
|
||||
import de.jeanlucmakiola.floret.time.formatDate
|
||||
@@ -240,6 +245,7 @@ fun TaskDetailScreen(
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
private fun DetailBody(
|
||||
detail: TaskDetail,
|
||||
@@ -453,6 +459,19 @@ private fun DetailBody(
|
||||
}
|
||||
}
|
||||
|
||||
if (task.tags.isNotEmpty()) {
|
||||
add {
|
||||
DetailCard(icon = Icons.Rounded.Sell, iconContentDescription = stringResource(R.string.edit_tags_label)) {
|
||||
FlowRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
task.tags.forEach { TagChip(it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Description — selectable, with its links tappable.
|
||||
task.description?.readableDescription()?.takeIf { it.isNotBlank() }?.let { description ->
|
||||
add {
|
||||
@@ -606,7 +625,7 @@ private fun AddSubtaskField(onAdd: (String) -> Unit) {
|
||||
}
|
||||
val accentOn = MaterialTheme.colorScheme.onPrimaryContainer
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().heightIn(min = 56.dp).padding(start = 8.dp, end = 8.dp),
|
||||
modifier = Modifier.fillMaxWidth().heightIn(min = 56.dp).revealOnFocus().padding(start = 8.dp, end = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Box(modifier = Modifier.size(48.dp), contentAlignment = Alignment.Center) {
|
||||
|
||||
@@ -1,9 +1,25 @@
|
||||
package de.jeanlucmakiola.agendula.ui.edit
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.slideInVertically
|
||||
import androidx.compose.animation.slideOutVertically
|
||||
import androidx.compose.foundation.ScrollState
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.ui.layout.onPlaced
|
||||
import androidx.compose.ui.layout.positionInParent
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import de.jeanlucmakiola.agendula.ui.common.TagChip
|
||||
import de.jeanlucmakiola.agendula.ui.common.LocalFirstDayOfWeek
|
||||
import de.jeanlucmakiola.agendula.ui.common.LocalUse24HourFormat
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.material.icons.rounded.Link
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.material.icons.rounded.Sell
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.material.icons.rounded.LocationOn
|
||||
import androidx.compose.material.icons.rounded.Repeat
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
@@ -15,6 +31,7 @@ import de.jeanlucmakiola.agendula.ui.common.icon
|
||||
import de.jeanlucmakiola.agendula.ui.common.labelRes
|
||||
import de.jeanlucmakiola.agendula.ui.common.recurrenceText
|
||||
import de.jeanlucmakiola.agendula.ui.common.reminderLeadTimeLabel
|
||||
import de.jeanlucmakiola.floret.components.revealOnFocus
|
||||
import de.jeanlucmakiola.floret.components.FullScreenPicker
|
||||
import de.jeanlucmakiola.floret.components.GroupedListInset
|
||||
import de.jeanlucmakiola.floret.components.GroupedSectionHeader
|
||||
@@ -153,11 +170,25 @@ fun TaskEditScreen(
|
||||
val requestBack = { if (state.isDirty && !state.saved) showDiscard = true else onBack() }
|
||||
BackHandler(enabled = state.isDirty && !state.saved) { showDiscard = true }
|
||||
|
||||
// Once the title field scrolls away it rides in the bar, so a long form never
|
||||
// loses track of which task is being edited.
|
||||
val scroll = rememberScrollState()
|
||||
var titleBottom by remember { mutableIntStateOf(Int.MAX_VALUE) }
|
||||
val titleInBar by remember { derivedStateOf { scroll.value > titleBottom } }
|
||||
|
||||
Scaffold(
|
||||
modifier = modifier,
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {},
|
||||
title = {
|
||||
AnimatedVisibility(
|
||||
visible = titleInBar && state.title.isNotBlank(),
|
||||
enter = fadeIn() + slideInVertically { it / 2 },
|
||||
exit = fadeOut() + slideOutVertically { it / 2 },
|
||||
) {
|
||||
Text(state.title, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = requestBack) {
|
||||
Icon(Icons.Rounded.Close, contentDescription = stringResource(R.string.close))
|
||||
@@ -180,6 +211,8 @@ fun TaskEditScreen(
|
||||
EditContent(
|
||||
state = state,
|
||||
viewModel = viewModel,
|
||||
scroll = scroll,
|
||||
onTitleBottom = { titleBottom = it },
|
||||
modifier = Modifier.padding(inner),
|
||||
)
|
||||
}
|
||||
@@ -220,6 +253,8 @@ private enum class PickerTarget { Start, Due }
|
||||
private fun EditContent(
|
||||
state: TaskEditUiState,
|
||||
viewModel: TaskEditViewModel,
|
||||
scroll: ScrollState,
|
||||
onTitleBottom: (Int) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val dark = isSystemInDarkTheme()
|
||||
@@ -250,7 +285,7 @@ private fun EditContent(
|
||||
// Shrink the scroll viewport by the keyboard so the focused field
|
||||
// scrolls into view above the IME instead of being hidden.
|
||||
.imePadding()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.verticalScroll(scroll)
|
||||
.padding(start = 24.dp, end = 24.dp, top = 8.dp, bottom = 40.dp),
|
||||
) {
|
||||
// Title: borderless headline + accent bar, mirroring the detail screen.
|
||||
@@ -259,7 +294,9 @@ private fun EditContent(
|
||||
onValueChange = viewModel::onTitleChange,
|
||||
placeholder = stringResource(R.string.edit_title_hint),
|
||||
textStyle = MaterialTheme.typography.headlineMedium.copy(fontWeight = FontWeight.SemiBold),
|
||||
modifier = Modifier.focusRequester(titleFocus),
|
||||
modifier = Modifier
|
||||
.focusRequester(titleFocus)
|
||||
.onPlaced { onTitleBottom(it.positionInParent().y.toInt() + it.size.height) },
|
||||
)
|
||||
Spacer(Modifier.height(10.dp))
|
||||
Box(
|
||||
@@ -572,6 +609,19 @@ private fun EditContent(
|
||||
}
|
||||
}
|
||||
|
||||
OptionalFormSection(visible = TaskFormField.Tags in state.visibleFields) {
|
||||
Spacer(Modifier.height(gap))
|
||||
TagsCard(
|
||||
tags = state.tags,
|
||||
draft = state.tagDraft,
|
||||
suggestions = state.tagSuggestions,
|
||||
onDraftChange = viewModel::onTagDraftChange,
|
||||
onCommitDraft = viewModel::commitTagDraft,
|
||||
onAdd = viewModel::addTag,
|
||||
onRemove = viewModel::removeTag,
|
||||
)
|
||||
}
|
||||
|
||||
OptionalFormSection(visible = state.hiddenFields.isNotEmpty()) {
|
||||
Spacer(Modifier.height(20.dp))
|
||||
TextButton(
|
||||
@@ -727,9 +777,9 @@ private fun EditCard(
|
||||
}
|
||||
}
|
||||
if (onClick != null) {
|
||||
Surface(onClick = onClick, color = color, shape = shape, modifier = Modifier.fillMaxWidth()) { inner() }
|
||||
Surface(onClick = onClick, color = color, shape = shape, modifier = Modifier.fillMaxWidth().revealOnFocus()) { inner() }
|
||||
} else {
|
||||
Surface(color = color, shape = shape, modifier = Modifier.fillMaxWidth()) { inner() }
|
||||
Surface(color = color, shape = shape, modifier = Modifier.fillMaxWidth().revealOnFocus()) { inner() }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1040,6 +1090,77 @@ private fun AddReminderCard(position: Position, label: String, onClick: () -> Un
|
||||
}
|
||||
}
|
||||
|
||||
/** The input sits beside the icon; the tags and any suggestions run below it. */
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
private fun TagsCard(
|
||||
tags: List<String>,
|
||||
draft: String,
|
||||
suggestions: List<String>,
|
||||
onDraftChange: (String) -> Unit,
|
||||
onCommitDraft: () -> Unit,
|
||||
onAdd: (String) -> Unit,
|
||||
onRemove: (String) -> Unit,
|
||||
) {
|
||||
val query = draft.trim()
|
||||
val matches = if (query.isEmpty()) {
|
||||
emptyList()
|
||||
} else {
|
||||
suggestions.filter { it.contains(query, ignoreCase = true) && it !in tags }.take(MAX_TAG_SUGGESTIONS)
|
||||
}
|
||||
EditCard(
|
||||
icon = Icons.Rounded.Sell,
|
||||
iconContentDescription = stringResource(R.string.edit_tags_label),
|
||||
header = {
|
||||
InlineTextField(
|
||||
value = draft,
|
||||
onValueChange = onDraftChange,
|
||||
placeholder = stringResource(R.string.edit_tags_hint),
|
||||
capitalization = KeyboardCapitalization.None,
|
||||
imeAction = ImeAction.Done,
|
||||
onImeAction = onCommitDraft,
|
||||
)
|
||||
},
|
||||
) {
|
||||
if (matches.isNotEmpty()) {
|
||||
TagFlow(modifier = Modifier.padding(top = 8.dp)) {
|
||||
matches.forEach { match ->
|
||||
TagChip(
|
||||
name = match,
|
||||
muted = true,
|
||||
icon = Icons.Rounded.Add,
|
||||
onClick = { onAdd(match) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (tags.isNotEmpty()) {
|
||||
TagFlow(modifier = Modifier.padding(top = 8.dp)) {
|
||||
tags.forEach { tag ->
|
||||
TagChip(
|
||||
name = tag,
|
||||
icon = Icons.Rounded.Close,
|
||||
iconContentDescription = stringResource(R.string.edit_tag_remove, tag),
|
||||
onClick = { onRemove(tag) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
private fun TagFlow(modifier: Modifier = Modifier, content: @Composable () -> Unit) {
|
||||
FlowRow(
|
||||
modifier = modifier,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) { content() }
|
||||
}
|
||||
|
||||
private const val MAX_TAG_SUGGESTIONS = 8
|
||||
|
||||
/** Picks one hidden optional section to add to the form. */
|
||||
@Composable
|
||||
private fun FieldPickerDialog(
|
||||
|
||||
@@ -8,15 +8,20 @@ import de.jeanlucmakiola.agendula.data.reminders.ReminderScheduler
|
||||
import de.jeanlucmakiola.agendula.data.tasks.TaskConflictException
|
||||
import de.jeanlucmakiola.agendula.data.tasks.TasksRepository
|
||||
import de.jeanlucmakiola.agendula.domain.Priority
|
||||
import de.jeanlucmakiola.agendula.domain.SmartList
|
||||
import de.jeanlucmakiola.agendula.domain.Task
|
||||
import de.jeanlucmakiola.agendula.domain.TaskFilter
|
||||
import de.jeanlucmakiola.agendula.domain.TaskForm
|
||||
import de.jeanlucmakiola.agendula.domain.TaskFormError
|
||||
import de.jeanlucmakiola.agendula.domain.TaskFormField
|
||||
import de.jeanlucmakiola.agendula.domain.allDayInstantOf
|
||||
import de.jeanlucmakiola.agendula.domain.TaskList
|
||||
import de.jeanlucmakiola.agendula.domain.populatedFields
|
||||
import de.jeanlucmakiola.agendula.domain.htmlToPlainText
|
||||
import de.jeanlucmakiola.agendula.domain.looksLikeHtml
|
||||
import de.jeanlucmakiola.agendula.domain.newTaskPreset
|
||||
import de.jeanlucmakiola.agendula.domain.normalizeTags
|
||||
import de.jeanlucmakiola.agendula.domain.tagsInUse
|
||||
import de.jeanlucmakiola.agendula.domain.rebasedForAllDay
|
||||
import de.jeanlucmakiola.agendula.domain.readableDescription
|
||||
import de.jeanlucmakiola.agendula.domain.recurrence.RecurringScope
|
||||
@@ -25,7 +30,9 @@ import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import java.time.LocalDate
|
||||
import javax.inject.Inject
|
||||
import kotlin.time.Clock
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
@@ -50,6 +57,11 @@ data class TaskEditUiState(
|
||||
val reminders: List<Int> = emptyList(),
|
||||
val location: String = "",
|
||||
val url: String = "",
|
||||
val tags: List<String> = emptyList(),
|
||||
/** What is typed in the tag field and not added yet. Saving still takes it. */
|
||||
val tagDraft: String = "",
|
||||
/** Every tag already in use, for completing what the user types. */
|
||||
val tagSuggestions: List<String> = emptyList(),
|
||||
val rrule: String? = null,
|
||||
/** Editing one occurrence of a series: saving asks how far the edit reaches. */
|
||||
val isOccurrence: Boolean = false,
|
||||
@@ -113,7 +125,13 @@ class TaskEditViewModel @Inject constructor(
|
||||
private var defaultFields: Set<TaskFormField> = emptySet()
|
||||
|
||||
/** Start a fresh task, optionally pre-selecting a list / parent. */
|
||||
fun bindNew(presetListId: Long? = null, parentId: Long? = null, initialTitle: String? = null) {
|
||||
fun bindNew(
|
||||
presetListId: Long? = null,
|
||||
parentId: Long? = null,
|
||||
initialTitle: String? = null,
|
||||
presetTag: String? = null,
|
||||
presetFilterId: Long? = null,
|
||||
) {
|
||||
if (bound) return
|
||||
bound = true
|
||||
editingTaskId = null
|
||||
@@ -122,11 +140,16 @@ class TaskEditViewModel @Inject constructor(
|
||||
val settings = settingsPrefs.settings.first()
|
||||
defaultFields = settings.defaultEditFields
|
||||
val lists = runCatching { repository.taskLists().first() }.getOrElse { emptyList() }
|
||||
// A read-only or vanished default would give the task a list it
|
||||
val preset = presetFilterId?.let { settingsPrefs.savedFilter(it).first() }
|
||||
?.newTaskPreset(LocalDate.now())
|
||||
// A read-only or vanished default — or a widget's preset list, if it was
|
||||
// since deleted or turned read-only — would give the task a list it
|
||||
// cannot be saved to.
|
||||
val writable = lists.filter { it.acceptsWrites }
|
||||
val defaultList = presetListId
|
||||
?: settings.defaultListId?.takeIf { id -> writable.any { it.id == id } }
|
||||
val filterLists = preset?.listIds.orEmpty()
|
||||
val defaultList = presetListId?.takeIf { id -> writable.any { it.id == id } }
|
||||
?: settings.defaultListId?.takeIf { id -> writable.any { it.id == id } && (filterLists.isEmpty() || id in filterLists) }
|
||||
?: writable.firstOrNull { it.id in filterLists }?.id
|
||||
?: writable.firstOrNull { !it.isLocal }?.id
|
||||
?: writable.firstOrNull()?.id
|
||||
_state.value = withFields(
|
||||
@@ -135,10 +158,22 @@ class TaskEditViewModel @Inject constructor(
|
||||
isNew = true,
|
||||
autofocusTitle = settings.autofocusTitle,
|
||||
listId = defaultList,
|
||||
// A preset due is all-day, so a default start has to be too, or
|
||||
// today's start would land after a due of today.
|
||||
start = when {
|
||||
!settings.defaultStartToday -> null
|
||||
preset?.dueDate != null -> allDayInstantOf(LocalDate.now())
|
||||
else -> Clock.System.now()
|
||||
},
|
||||
// The same all-day value picking the date in the date field gives.
|
||||
due = preset?.dueDate?.let(::allDayInstantOf),
|
||||
isAllDay = preset?.dueDate != null,
|
||||
priority = preset?.priority ?: Priority.NONE,
|
||||
parentId = parentId,
|
||||
lists = lists,
|
||||
parentCandidates = loadParents(defaultList, selfId = null),
|
||||
title = initialTitle.orEmpty(),
|
||||
tags = (listOfNotNull(presetTag) + preset?.tags.orEmpty()).distinct(),
|
||||
),
|
||||
).also { baseline = it.toForm().copy(title = "") }
|
||||
}
|
||||
@@ -176,6 +211,7 @@ class TaskEditViewModel @Inject constructor(
|
||||
reminders = repository.remindersFor(taskId).filter { !it.fromStart }.map { it.minutesBefore },
|
||||
location = task.location.orEmpty(),
|
||||
url = task.url.orEmpty(),
|
||||
tags = task.tags,
|
||||
rrule = task.recurrenceRule,
|
||||
originalRule = task.recurrenceRule,
|
||||
isOccurrence = task.isOccurrence,
|
||||
@@ -199,6 +235,18 @@ class TaskEditViewModel @Inject constructor(
|
||||
return tasks.filter { !it.isClosed && !it.isSubtask && it.taskId != selfId }
|
||||
}
|
||||
|
||||
private var suggestionsRequested = false
|
||||
|
||||
/** Tags on open tasks, read once the user starts typing one rather than on every bind. */
|
||||
private fun loadTagSuggestions() {
|
||||
if (suggestionsRequested) return
|
||||
suggestionsRequested = true
|
||||
viewModelScope.launch {
|
||||
val tasks = runCatching { repository.tasks(TaskFilter.Smart(SmartList.ALL)).first() }.getOrElse { emptyList() }
|
||||
_state.value = _state.value.copy(tagSuggestions = tagsInUse(tasks))
|
||||
}
|
||||
}
|
||||
|
||||
/** Reveal an optional section (one tap from the "More fields" picker). */
|
||||
fun revealField(field: TaskFormField) = update {
|
||||
val visible = it.visibleFields + field
|
||||
@@ -254,6 +302,18 @@ class TaskEditViewModel @Inject constructor(
|
||||
fun removeReminder(minutesBeforeDue: Int) = update { it.copy(reminders = it.reminders - minutesBeforeDue) }
|
||||
fun onLocationChange(value: String) = update { it.copy(location = value) }
|
||||
fun onUrlChange(value: String) = update { it.copy(url = value) }
|
||||
fun addTag(name: String) = update { it.copy(tags = normalizeTags(it.tags + name), tagDraft = "") }
|
||||
|
||||
/** A typed comma adds what came before it. */
|
||||
fun onTagDraftChange(value: String) {
|
||||
loadTagSuggestions()
|
||||
update {
|
||||
if (',' in value) it.copy(tags = it.withDraft(value), tagDraft = "") else it.copy(tagDraft = value)
|
||||
}
|
||||
}
|
||||
|
||||
fun commitTagDraft() = update { it.copy(tags = it.withDraft(it.tagDraft), tagDraft = "") }
|
||||
fun removeTag(name: String) = update { it.copy(tags = it.tags - name) }
|
||||
fun onRecurrenceChange(rrule: String?) = update { it.copy(rrule = rrule, errors = emptySet()) }
|
||||
|
||||
/** Dismiss the conflict prompt without saving (the user keeps editing). */
|
||||
@@ -336,5 +396,8 @@ private fun TaskEditUiState.toForm(): TaskForm = TaskForm(
|
||||
reminders = reminders,
|
||||
location = location.ifBlank { null },
|
||||
url = url.ifBlank { null },
|
||||
tags = withDraft(tagDraft),
|
||||
rrule = rrule,
|
||||
)
|
||||
|
||||
private fun TaskEditUiState.withDraft(draft: String): List<String> = normalizeTags(tags + draft.split(','))
|
||||
@@ -0,0 +1,259 @@
|
||||
package de.jeanlucmakiola.agendula.ui.filters
|
||||
|
||||
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.ChevronRight
|
||||
import androidx.compose.material.icons.rounded.Delete
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Switch
|
||||
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.saveable.rememberSaveable
|
||||
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.domain.DueWindow
|
||||
import de.jeanlucmakiola.agendula.domain.Priority
|
||||
import de.jeanlucmakiola.agendula.domain.SavedFilter
|
||||
import de.jeanlucmakiola.agendula.domain.TagMatch
|
||||
import de.jeanlucmakiola.agendula.ui.common.ListColorChip
|
||||
import de.jeanlucmakiola.agendula.ui.common.NameField
|
||||
import de.jeanlucmakiola.floret.components.CollapsingScaffold
|
||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||
import de.jeanlucmakiola.floret.components.GroupedSectionHeader
|
||||
import de.jeanlucmakiola.floret.components.OptionPicker
|
||||
import de.jeanlucmakiola.floret.components.Position
|
||||
import de.jeanlucmakiola.floret.components.SelectedCheck
|
||||
import de.jeanlucmakiola.floret.components.positionOf
|
||||
|
||||
/**
|
||||
* Creates or edits a [SavedFilter]: a name, then due windows, priority, lists and
|
||||
* tags. Each multi-choice section leads with an "any" row that is selected while
|
||||
* nothing in it is, so an untouched section visibly doesn't narrow.
|
||||
*/
|
||||
@Composable
|
||||
fun FilterEditScreen(
|
||||
viewModel: FilterEditViewModel,
|
||||
isNew: Boolean,
|
||||
onSaved: (Long) -> Unit,
|
||||
onDeleted: () -> Unit,
|
||||
onBack: () -> Unit,
|
||||
) {
|
||||
val draft by viewModel.draft.collectAsStateWithLifecycle()
|
||||
val lists by viewModel.lists.collectAsStateWithLifecycle()
|
||||
val tagsInUse by viewModel.tags.collectAsStateWithLifecycle()
|
||||
val filter = draft
|
||||
var choosingPriority by rememberSaveable { mutableStateOf(false) }
|
||||
var confirmDelete by rememberSaveable { mutableStateOf(false) }
|
||||
val save = { viewModel.save(onSaved) }
|
||||
|
||||
CollapsingScaffold(
|
||||
title = stringResource(if (isNew) R.string.filter_new_title else R.string.filter_edit_title),
|
||||
onBack = onBack,
|
||||
largeTopBar = false,
|
||||
actions = {
|
||||
Button(
|
||||
onClick = save,
|
||||
enabled = filter?.name?.isNotBlank() == true,
|
||||
modifier = Modifier.padding(end = 12.dp),
|
||||
) { Text(stringResource(R.string.save)) }
|
||||
},
|
||||
) {
|
||||
if (filter == null) return@CollapsingScaffold
|
||||
|
||||
NameField(
|
||||
name = filter.name,
|
||||
placeholder = stringResource(R.string.filter_name_hint),
|
||||
autoFocus = isNew,
|
||||
onNameChange = { name -> viewModel.update { it.copy(name = name) } },
|
||||
onImeAction = save,
|
||||
)
|
||||
|
||||
GroupedSectionHeader(stringResource(R.string.filter_section_due))
|
||||
val windows = DueWindow.entries
|
||||
ChoiceRow(
|
||||
title = stringResource(R.string.filter_any_due),
|
||||
position = positionOf(0, windows.size + 1),
|
||||
selected = filter.due.isEmpty(),
|
||||
onClick = { viewModel.update { it.copy(due = emptySet()) } },
|
||||
)
|
||||
windows.forEachIndexed { index, window ->
|
||||
ChoiceRow(
|
||||
title = dueWindowLabel(window),
|
||||
position = positionOf(index + 1, windows.size + 1),
|
||||
selected = window in filter.due,
|
||||
onClick = { viewModel.update { it.copy(due = it.due.toggle(window)) } },
|
||||
)
|
||||
}
|
||||
|
||||
GroupedSectionHeader(stringResource(R.string.filter_section_priority))
|
||||
GroupedRow(
|
||||
title = minPriorityLabel(filter.minPriority),
|
||||
position = Position.Alone,
|
||||
trailing = { Icon(Icons.Rounded.ChevronRight, contentDescription = null) },
|
||||
onClick = { choosingPriority = true },
|
||||
)
|
||||
|
||||
if (lists.isNotEmpty()) {
|
||||
GroupedSectionHeader(stringResource(R.string.filter_section_lists))
|
||||
val showAccount = lists.mapTo(HashSet()) { it.accountName }.size > 1
|
||||
ChoiceRow(
|
||||
title = stringResource(R.string.filter_any_list),
|
||||
position = positionOf(0, lists.size + 1),
|
||||
selected = filter.anyList,
|
||||
onClick = { viewModel.update { it.copy(listIds = emptySet(), listsElsewhere = false) } },
|
||||
)
|
||||
lists.forEachIndexed { index, list ->
|
||||
ChoiceRow(
|
||||
title = list.name,
|
||||
summary = list.accountName.takeIf { showAccount },
|
||||
position = positionOf(index + 1, lists.size + 1),
|
||||
selected = list.id in filter.listIds,
|
||||
leading = { ListColorChip(list.color) },
|
||||
onClick = { viewModel.update { it.copy(listIds = it.listIds.toggle(list.id)) } },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// A tag no open task carries any more stays listed while the filter uses
|
||||
// it, so it can still be unticked.
|
||||
val tags = remember(tagsInUse, filter.tags) {
|
||||
(tagsInUse + filter.tags).distinct().sortedWith(String.CASE_INSENSITIVE_ORDER)
|
||||
}
|
||||
if (tags.isNotEmpty()) {
|
||||
GroupedSectionHeader(stringResource(R.string.filter_section_tags))
|
||||
ChoiceRow(
|
||||
title = stringResource(R.string.filter_any_tag),
|
||||
position = positionOf(0, tags.size + 1),
|
||||
selected = filter.tags.isEmpty(),
|
||||
onClick = { viewModel.update { it.copy(tags = emptySet()) } },
|
||||
)
|
||||
tags.forEachIndexed { index, tag ->
|
||||
ChoiceRow(
|
||||
title = tag,
|
||||
position = positionOf(index + 1, tags.size + 1),
|
||||
selected = tag in filter.tags,
|
||||
onClick = { viewModel.update { it.copy(tags = it.tags.toggle(tag)) } },
|
||||
)
|
||||
}
|
||||
if (filter.tags.size > 1) {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.filter_match_all_tags),
|
||||
summary = stringResource(
|
||||
if (filter.tagMatch == TagMatch.ALL) R.string.filter_match_all_on else R.string.filter_match_all_off,
|
||||
),
|
||||
position = Position.Alone,
|
||||
trailing = {
|
||||
Switch(
|
||||
checked = filter.tagMatch == TagMatch.ALL,
|
||||
onCheckedChange = { all ->
|
||||
viewModel.update { it.copy(tagMatch = if (all) TagMatch.ALL else TagMatch.ANY) }
|
||||
},
|
||||
)
|
||||
},
|
||||
onClick = {
|
||||
viewModel.update { it.copy(tagMatch = if (it.tagMatch == TagMatch.ALL) TagMatch.ANY else TagMatch.ALL) }
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (filter.id > 0) {
|
||||
Spacer(Modifier.height(24.dp))
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.filter_delete),
|
||||
position = Position.Alone,
|
||||
contentColor = MaterialTheme.colorScheme.error,
|
||||
leading = { Icon(Icons.Rounded.Delete, contentDescription = null) },
|
||||
onClick = { confirmDelete = true },
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(24.dp))
|
||||
}
|
||||
|
||||
if (choosingPriority && filter != null) {
|
||||
OptionPicker(
|
||||
title = stringResource(R.string.filter_section_priority),
|
||||
options = Priority.entries,
|
||||
selected = filter.minPriority,
|
||||
label = { minPriorityLabel(it) },
|
||||
onSelect = { priority -> viewModel.update { it.copy(minPriority = priority) } },
|
||||
onDismiss = { choosingPriority = false },
|
||||
)
|
||||
}
|
||||
|
||||
if (confirmDelete && filter != null) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { confirmDelete = false },
|
||||
title = { Text(stringResource(R.string.filter_delete_title, filter.name)) },
|
||||
text = { Text(stringResource(R.string.filter_delete_body)) },
|
||||
confirmButton = {
|
||||
TextButton(onClick = { confirmDelete = false; viewModel.delete(onDeleted) }) {
|
||||
Text(stringResource(R.string.filter_delete_confirm))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { confirmDelete = false }) { Text(stringResource(R.string.dialog_cancel)) }
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ChoiceRow(
|
||||
title: String,
|
||||
position: Position,
|
||||
selected: Boolean,
|
||||
onClick: () -> Unit,
|
||||
summary: String? = null,
|
||||
leading: @Composable (() -> Unit)? = null,
|
||||
) {
|
||||
GroupedRow(
|
||||
title = title,
|
||||
summary = summary,
|
||||
position = position,
|
||||
selected = selected,
|
||||
leading = leading,
|
||||
trailing = if (selected) {
|
||||
{ SelectedCheck() }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
onClick = onClick,
|
||||
)
|
||||
}
|
||||
|
||||
private fun <T> Set<T>.toggle(item: T): Set<T> = if (item in this) this - item else this + item
|
||||
|
||||
@Composable
|
||||
private fun dueWindowLabel(window: DueWindow): String = stringResource(
|
||||
when (window) {
|
||||
DueWindow.OVERDUE -> R.string.smart_overdue
|
||||
DueWindow.TODAY -> R.string.smart_today
|
||||
DueWindow.UPCOMING -> R.string.smart_upcoming
|
||||
DueWindow.NO_DATE -> R.string.smart_no_date
|
||||
},
|
||||
)
|
||||
|
||||
@Composable
|
||||
internal fun minPriorityLabel(priority: Priority): String = stringResource(
|
||||
when (priority) {
|
||||
Priority.NONE -> R.string.filter_priority_any
|
||||
Priority.LOW -> R.string.filter_priority_low
|
||||
Priority.MEDIUM -> R.string.filter_priority_medium
|
||||
Priority.HIGH -> R.string.filter_priority_high
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,91 @@
|
||||
package de.jeanlucmakiola.agendula.ui.filters
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.agendula.data.tasks.TasksRepository
|
||||
import de.jeanlucmakiola.agendula.domain.SavedFilter
|
||||
import de.jeanlucmakiola.agendula.domain.SmartList
|
||||
import de.jeanlucmakiola.agendula.domain.TaskFilter
|
||||
import de.jeanlucmakiola.agendula.domain.TaskList
|
||||
import de.jeanlucmakiola.agendula.domain.tagsInUse
|
||||
import de.jeanlucmakiola.agendula.widget.TaskWidgetUpdater
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
/** Holds the filter being created or edited until it's saved. Call [bind] first. */
|
||||
@HiltViewModel
|
||||
class FilterEditViewModel @Inject constructor(
|
||||
private val settingsPrefs: SettingsPrefs,
|
||||
private val widgetUpdater: TaskWidgetUpdater,
|
||||
private val repository: TasksRepository,
|
||||
) : ViewModel() {
|
||||
|
||||
private val _draft = MutableStateFlow<SavedFilter?>(null)
|
||||
|
||||
/** `null` until [bind] has loaded the filter. An id of 0 is a new one. */
|
||||
val draft: StateFlow<SavedFilter?> = _draft.asStateFlow()
|
||||
|
||||
val lists: StateFlow<List<TaskList>> = repository.taskLists()
|
||||
.catch { emit(emptyList()) }
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
|
||||
|
||||
/** Every tag on an open task. */
|
||||
val tags: StateFlow<List<String>> = repository.tasks(TaskFilter.Smart(SmartList.ALL))
|
||||
.map { tagsInUse(it) }
|
||||
.catch { emit(emptyList()) }
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
|
||||
|
||||
private var bound = false
|
||||
private var saving = false
|
||||
|
||||
fun bind(filterId: Long?) {
|
||||
if (bound) return
|
||||
bound = true
|
||||
if (filterId == null) {
|
||||
_draft.value = SavedFilter(id = 0, name = "")
|
||||
return
|
||||
}
|
||||
viewModelScope.launch {
|
||||
val saved = settingsPrefs.savedFilter(filterId).first()
|
||||
// Show it as it matches: lists deleted since drop out, as they do in the repository.
|
||||
val existing = runCatching { repository.taskLists().first().mapTo(HashSet()) { it.id } }.getOrNull()
|
||||
_draft.value = saved?.let { if (existing == null) it else it.withListsIn(existing) }
|
||||
?: SavedFilter(id = 0, name = "")
|
||||
}
|
||||
}
|
||||
|
||||
fun update(transform: (SavedFilter) -> SavedFilter) {
|
||||
_draft.value = _draft.value?.let(transform)
|
||||
}
|
||||
|
||||
fun save(onSaved: (Long) -> Unit) {
|
||||
val filter = _draft.value ?: return
|
||||
if (filter.name.isBlank() || saving) return
|
||||
saving = true
|
||||
viewModelScope.launch {
|
||||
val id = settingsPrefs.saveFilter(filter.copy(name = filter.name.trim()))
|
||||
// A widget showing this filter doesn't watch the prefs.
|
||||
widgetUpdater.requestRefresh()
|
||||
onSaved(id)
|
||||
}
|
||||
}
|
||||
|
||||
fun delete(onDeleted: () -> Unit) {
|
||||
val id = _draft.value?.id?.takeIf { it > 0 } ?: return
|
||||
viewModelScope.launch {
|
||||
settingsPrefs.deleteFilter(id)
|
||||
widgetUpdater.requestRefresh()
|
||||
onDeleted()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import android.net.Uri
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import de.jeanlucmakiola.agendula.data.sync.SyncTrigger
|
||||
import de.jeanlucmakiola.agendula.data.sync.SyncRequests
|
||||
import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver
|
||||
import de.jeanlucmakiola.agendula.data.tasks.StorageMode
|
||||
import de.jeanlucmakiola.agendula.data.tasks.TasksRepository
|
||||
@@ -76,7 +76,7 @@ class ImportViewModel @Inject constructor(
|
||||
repository: TasksRepository,
|
||||
private val resolver: ProviderResolver,
|
||||
private val importer: IcsImport,
|
||||
private val syncTrigger: SyncTrigger,
|
||||
private val syncRequests: SyncRequests,
|
||||
) : ViewModel() {
|
||||
|
||||
private val ownStore = MutableStateFlow(resolver.mode() == StorageMode.OWN)
|
||||
@@ -161,7 +161,7 @@ class ImportViewModel @Inject constructor(
|
||||
val result = importer.import(ready.parsed, target)
|
||||
(chosen as? ImportChoice.Existing)?.list
|
||||
?.takeIf { it.accountId != null && result.imported > 0 }
|
||||
?.let { runCatching { syncTrigger.enqueue(it.accountName) } }
|
||||
?.let { runCatching { syncRequests.syncNow(it.accountName) } }
|
||||
ImportStep.Done(
|
||||
listName = listName,
|
||||
imported = result.imported,
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package de.jeanlucmakiola.agendula.ui.licences
|
||||
|
||||
import de.jeanlucmakiola.agendula.BuildConfig
|
||||
|
||||
/**
|
||||
* The third-party code Agendula ships, and what each licence obliges us to say.
|
||||
*
|
||||
@@ -30,6 +32,8 @@ data class Attribution(
|
||||
val licence: Licence,
|
||||
/** Where the source can actually be obtained — MPL-2.0 §3.2(a)'s requirement. */
|
||||
val sourceUrl: String,
|
||||
/** Only in the `full` flavor, which carries CalDAV sync and push. */
|
||||
val syncOnly: Boolean = false,
|
||||
)
|
||||
|
||||
enum class Licence(val spdxId: String, val url: String) {
|
||||
@@ -54,30 +58,35 @@ object OpenSourceLicences {
|
||||
copyright = "© bitfire web engineering (Ricki Hirner, Bernhard Stockmann)",
|
||||
licence = Licence.MPL_2,
|
||||
sourceUrl = "https://github.com/bitfireAT/dav4jvm",
|
||||
syncOnly = true,
|
||||
),
|
||||
Attribution(
|
||||
name = "dnsjava",
|
||||
copyright = "© Brian Wellington and the dnsjava contributors",
|
||||
licence = Licence.BSD_3,
|
||||
sourceUrl = "https://github.com/dnsjava/dnsjava",
|
||||
syncOnly = true,
|
||||
),
|
||||
Attribution(
|
||||
name = "UnifiedPush Android connector",
|
||||
copyright = "© UnifiedPush contributors",
|
||||
licence = Licence.APACHE_2,
|
||||
sourceUrl = "https://codeberg.org/UnifiedPush/android-connector",
|
||||
syncOnly = true,
|
||||
),
|
||||
Attribution(
|
||||
name = "Tink",
|
||||
copyright = "© Google LLC",
|
||||
licence = Licence.APACHE_2,
|
||||
sourceUrl = "https://github.com/tink-crypto/tink-java",
|
||||
syncOnly = true,
|
||||
),
|
||||
Attribution(
|
||||
name = "OkHttp",
|
||||
copyright = "© Square, Inc.",
|
||||
licence = Licence.APACHE_2,
|
||||
sourceUrl = "https://github.com/square/okhttp",
|
||||
syncOnly = true,
|
||||
),
|
||||
Attribution(
|
||||
name = "Okio",
|
||||
@@ -121,5 +130,5 @@ object OpenSourceLicences {
|
||||
licence = Licence.MIT,
|
||||
sourceUrl = "https://codeberg.org/jlmakiola/floret-kit",
|
||||
),
|
||||
)
|
||||
).filter { BuildConfig.SYNC_ENABLED || !it.syncOnly }
|
||||
}
|
||||
@@ -30,7 +30,6 @@ import androidx.compose.material3.Switch
|
||||
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.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
@@ -40,25 +39,22 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.luminance
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.unit.dp
|
||||
import de.jeanlucmakiola.agendula.R
|
||||
import de.jeanlucmakiola.agendula.domain.TaskList
|
||||
import de.jeanlucmakiola.agendula.ui.common.DefaultListColor
|
||||
import de.jeanlucmakiola.agendula.ui.common.ListColorChip
|
||||
import de.jeanlucmakiola.agendula.ui.common.ListPalette
|
||||
import de.jeanlucmakiola.agendula.ui.common.NameField
|
||||
import de.jeanlucmakiola.floret.components.FullScreenPicker
|
||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||
import de.jeanlucmakiola.floret.components.GroupedSurface
|
||||
import de.jeanlucmakiola.floret.components.InlineTextField
|
||||
import de.jeanlucmakiola.floret.components.OptionPicker
|
||||
import de.jeanlucmakiola.floret.components.Position
|
||||
import de.jeanlucmakiola.floret.components.pastelize
|
||||
@@ -153,7 +149,6 @@ fun ListEditorSheet(
|
||||
GroupedRow(
|
||||
title = destination.label,
|
||||
position = Position.Alone,
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
leading = { Icon(destinationIcon(destination), contentDescription = null) },
|
||||
trailing = { Icon(Icons.Rounded.ChevronRight, contentDescription = null) },
|
||||
onClick = { choosingDestination = true },
|
||||
@@ -176,7 +171,6 @@ fun ListEditorSheet(
|
||||
title = stringResource(R.string.list_in_smart_lists),
|
||||
summary = stringResource(R.string.list_in_smart_lists_hint),
|
||||
position = Position.Alone,
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
trailing = {
|
||||
Switch(
|
||||
checked = !hiddenFromSmartLists,
|
||||
@@ -260,27 +254,14 @@ internal fun ListNameField(
|
||||
autoFocus: Boolean,
|
||||
onNameChange: (String) -> Unit,
|
||||
onImeAction: () -> Unit,
|
||||
) {
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
LaunchedEffect(autoFocus) { if (autoFocus) focusRequester.requestFocus() }
|
||||
GroupedSurface(position = Position.Alone, modifier = Modifier.padding(horizontal = 16.dp)) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().heightIn(min = 72.dp).padding(horizontal = 16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
ListColorChip(color)
|
||||
InlineTextField(
|
||||
value = name,
|
||||
onValueChange = onNameChange,
|
||||
placeholder = stringResource(R.string.list_name_hint),
|
||||
imeAction = ImeAction.Done,
|
||||
onImeAction = onImeAction,
|
||||
modifier = Modifier.fillMaxWidth().focusRequester(focusRequester),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
) = NameField(
|
||||
name = name,
|
||||
placeholder = stringResource(R.string.list_name_hint),
|
||||
autoFocus = autoFocus,
|
||||
onNameChange = onNameChange,
|
||||
onImeAction = onImeAction,
|
||||
leading = { ListColorChip(color) },
|
||||
)
|
||||
|
||||
/**
|
||||
* The palette as two rows of round swatches; the chosen one carries a check.
|
||||
|
||||
@@ -3,6 +3,8 @@ package de.jeanlucmakiola.agendula.ui.lists
|
||||
import de.jeanlucmakiola.floret.components.ReorderableColumn
|
||||
import androidx.compose.material.icons.rounded.Check
|
||||
import androidx.compose.material.icons.rounded.DragHandle
|
||||
import androidx.compose.material.icons.rounded.FilterList
|
||||
import androidx.compose.material.icons.rounded.Sell
|
||||
import androidx.compose.material.icons.rounded.SwapVert
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
@@ -30,6 +32,7 @@ import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.statusBarsPadding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyListScope
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
@@ -86,6 +89,7 @@ import de.jeanlucmakiola.agendula.domain.Task
|
||||
import de.jeanlucmakiola.agendula.domain.TaskFilter
|
||||
import de.jeanlucmakiola.agendula.ui.common.ActionShapes
|
||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||
import de.jeanlucmakiola.floret.components.Position
|
||||
import de.jeanlucmakiola.floret.components.SnackChip
|
||||
import de.jeanlucmakiola.floret.components.SnackChipHeight
|
||||
import de.jeanlucmakiola.floret.components.SnackChipMargin
|
||||
@@ -114,6 +118,7 @@ fun ListsScreen(
|
||||
onNewTask: () -> Unit,
|
||||
onOpenSettings: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
onNewFilter: () -> Unit = {},
|
||||
viewModel: ListsViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
@@ -179,6 +184,7 @@ fun ListsScreen(
|
||||
onOpenFilter = onOpenFilter,
|
||||
onOpenTask = onOpenTask,
|
||||
onNewList = { newList = true },
|
||||
onNewFilter = onNewFilter,
|
||||
onReorder = viewModel::reorderLists,
|
||||
topPadding = 0.dp,
|
||||
bottomPadding = inner.calculateBottomPadding() + 96.dp,
|
||||
@@ -252,6 +258,7 @@ private fun ListsContent(
|
||||
onOpenFilter: (TaskFilter) -> Unit,
|
||||
onOpenTask: (Task) -> Unit,
|
||||
onNewList: () -> Unit,
|
||||
onNewFilter: () -> Unit,
|
||||
onReorder: (List<Long>) -> Unit,
|
||||
topPadding: androidx.compose.ui.unit.Dp,
|
||||
bottomPadding: androidx.compose.ui.unit.Dp,
|
||||
@@ -370,6 +377,90 @@ private fun ListsContent(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (state.tags.isNotEmpty()) {
|
||||
item(key = "tags-header") { SectionHeader(stringResource(R.string.tags_header)) }
|
||||
itemsIndexed(state.tags, key = { _, t -> "tag-${t.tag}" }) { index, overview ->
|
||||
GroupedRow(
|
||||
title = overview.tag,
|
||||
position = positionOf(index, state.tags.size),
|
||||
leading = {
|
||||
Icon(
|
||||
Icons.Rounded.Sell,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
},
|
||||
trailing = {
|
||||
Text(
|
||||
overview.openCount.toString(),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
},
|
||||
onClick = { onOpenFilter(TaskFilter.Tagged(overview.tag)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (state.groups.isNotEmpty()) {
|
||||
filtersSection(state.filters, onOpenFilter, onNewFilter)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The saved filters, or a single row offering the first one. */
|
||||
private fun LazyListScope.filtersSection(
|
||||
filters: List<FilterOverview>,
|
||||
onOpenFilter: (TaskFilter) -> Unit,
|
||||
onNewFilter: () -> Unit,
|
||||
) {
|
||||
item(key = "filters-header") {
|
||||
SectionHeader(
|
||||
text = stringResource(R.string.filters_header),
|
||||
action = if (filters.isEmpty()) {
|
||||
null
|
||||
} else {
|
||||
{
|
||||
IconButton(onClick = onNewFilter) {
|
||||
Icon(Icons.Rounded.Add, contentDescription = stringResource(R.string.filter_new_title))
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
if (filters.isEmpty()) {
|
||||
item(key = "filters-empty") {
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.filter_new_title),
|
||||
summary = stringResource(R.string.filters_empty_hint),
|
||||
position = Position.Alone,
|
||||
leading = { Icon(Icons.Rounded.Add, contentDescription = null) },
|
||||
onClick = onNewFilter,
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
itemsIndexed(filters, key = { _, f -> "filter-${f.filter.id}" }) { index, overview ->
|
||||
GroupedRow(
|
||||
title = overview.filter.name,
|
||||
position = positionOf(index, filters.size),
|
||||
leading = {
|
||||
Icon(
|
||||
Icons.Rounded.FilterList,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
},
|
||||
trailing = {
|
||||
Text(
|
||||
overview.openCount.toString(),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
},
|
||||
onClick = { onOpenFilter(TaskFilter.Saved(overview.filter.id)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,15 +4,17 @@ import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.agendula.data.sync.RemoteListRepository
|
||||
import de.jeanlucmakiola.agendula.data.sync.RemoteLists
|
||||
import de.jeanlucmakiola.agendula.data.tasks.TasksRepository
|
||||
import de.jeanlucmakiola.agendula.data.tasks.recoveringFromProviderFailure
|
||||
import de.jeanlucmakiola.floret.time.DayWindow
|
||||
import de.jeanlucmakiola.agendula.domain.SavedFilter
|
||||
import de.jeanlucmakiola.agendula.domain.SmartList
|
||||
import de.jeanlucmakiola.agendula.domain.Task
|
||||
import de.jeanlucmakiola.agendula.domain.TaskFilter
|
||||
import de.jeanlucmakiola.agendula.domain.TaskFiltering
|
||||
import de.jeanlucmakiola.agendula.domain.TaskList
|
||||
import de.jeanlucmakiola.agendula.domain.tagsInUse
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
@@ -54,25 +56,27 @@ enum class ListWriteFailure {
|
||||
}
|
||||
|
||||
/** Maps a remote outcome onto the wording the screens already know how to show. */
|
||||
internal fun RemoteListRepository.Outcome.asFailure(local: ListWriteFailure): ListWriteFailure? =
|
||||
internal fun RemoteLists.Outcome.asFailure(local: ListWriteFailure): ListWriteFailure? =
|
||||
when (this) {
|
||||
RemoteListRepository.Outcome.Done -> null
|
||||
is RemoteListRepository.Outcome.Refused -> ListWriteFailure.SERVER_REFUSED
|
||||
RemoteListRepository.Outcome.Unreachable -> ListWriteFailure.OFFLINE
|
||||
RemoteListRepository.Outcome.Unsupported -> ListWriteFailure.UNSUPPORTED
|
||||
RemoteListRepository.Outcome.ReadOnly -> ListWriteFailure.READ_ONLY
|
||||
RemoteLists.Outcome.Done -> null
|
||||
is RemoteLists.Outcome.Refused -> ListWriteFailure.SERVER_REFUSED
|
||||
RemoteLists.Outcome.Unreachable -> ListWriteFailure.OFFLINE
|
||||
RemoteLists.Outcome.Unsupported -> ListWriteFailure.UNSUPPORTED
|
||||
RemoteLists.Outcome.ReadOnly -> ListWriteFailure.READ_ONLY
|
||||
// The account is gone, stopped or undecryptable, or the server said
|
||||
// something this call cannot place. Nothing specific to say beyond
|
||||
// "that did not save", which is what [local] is — and specifically not
|
||||
// "you are offline", which neither of them is a claim about.
|
||||
RemoteListRepository.Outcome.NoAccount,
|
||||
RemoteListRepository.Outcome.Unexpected,
|
||||
RemoteLists.Outcome.NoAccount,
|
||||
RemoteLists.Outcome.Unexpected,
|
||||
-> local
|
||||
}
|
||||
|
||||
data class ListOverview(val list: TaskList, val openCount: Int)
|
||||
data class AccountGroup(val accountName: String, val lists: List<ListOverview>)
|
||||
data class SmartCount(val smart: SmartList, val count: Int)
|
||||
data class TagOverview(val tag: String, val openCount: Int)
|
||||
data class FilterOverview(val filter: SavedFilter, val openCount: Int)
|
||||
|
||||
sealed interface ListsUiState {
|
||||
data object Loading : ListsUiState
|
||||
@@ -87,6 +91,10 @@ sealed interface ListsUiState {
|
||||
val upcoming: List<Task>,
|
||||
/** Every task (open and completed) — the corpus the home search filters. */
|
||||
val allTasks: List<Task>,
|
||||
/** Each tag on an open task, alphabetically. */
|
||||
val tags: List<TagOverview> = emptyList(),
|
||||
/** The saved filters, oldest first. */
|
||||
val filters: List<FilterOverview> = emptyList(),
|
||||
) : ListsUiState
|
||||
}
|
||||
|
||||
@@ -96,7 +104,7 @@ private const val UPCOMING_PREVIEW = 3
|
||||
@HiltViewModel
|
||||
class ListsViewModel @Inject constructor(
|
||||
private val repository: TasksRepository,
|
||||
private val remoteLists: RemoteListRepository,
|
||||
private val remoteLists: RemoteLists,
|
||||
settingsPrefs: SettingsPrefs,
|
||||
) : ViewModel() {
|
||||
|
||||
@@ -125,8 +133,9 @@ class ListsViewModel @Inject constructor(
|
||||
// ones already ticked off to show "x of y done", so read them too.
|
||||
repository.tasks(TaskFilter.Smart(SmartList.COMPLETED)),
|
||||
settingsPrefs.settings.map { it.hiddenFromSmartLists }.distinctUntilChanged(),
|
||||
) { lists, openTasks, completedTasks, hidden ->
|
||||
buildContent(lists, openTasks, completedTasks, hidden) as ListsUiState
|
||||
settingsPrefs.savedFilters,
|
||||
) { lists, openTasks, completedTasks, hidden, filters ->
|
||||
buildContent(lists, openTasks, completedTasks, hidden, filters) as ListsUiState
|
||||
}.recoveringFromProviderFailure { ListsUiState.Failure }
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), ListsUiState.Loading)
|
||||
|
||||
@@ -135,6 +144,7 @@ class ListsViewModel @Inject constructor(
|
||||
openTasks: List<Task>,
|
||||
completedTasks: List<Task>,
|
||||
hidden: Set<Long>,
|
||||
savedFilters: List<SavedFilter>,
|
||||
): ListsUiState.Content {
|
||||
val (todayStart, todayEnd) = DayWindow.today(Clock.System.now(), ZoneId.systemDefault())
|
||||
// Count only top-level tasks: a subtask is represented by its parent (and
|
||||
@@ -177,13 +187,29 @@ class ListsViewModel @Inject constructor(
|
||||
)
|
||||
}
|
||||
.sortedBy { it.accountName.lowercase() }
|
||||
// Counts the open rows the tag's own screen shows: a subtask folds under a
|
||||
// parent carrying the same tag, and stands on its own otherwise.
|
||||
val allTasks = openTasks + completedTasks
|
||||
val tags = tagsInUse(openTasks).map { tag ->
|
||||
val tagged = allTasks.filter { tag in it.tags }
|
||||
val ids = tagged.mapTo(HashSet()) { it.taskId }
|
||||
TagOverview(tag, tagged.count { !it.isClosed && (!it.isSubtask || it.parentId !in ids) })
|
||||
}
|
||||
// Same folding as the tags: a subtask counts only when its parent isn't in the filter too.
|
||||
val filters = savedFilters.map { filter ->
|
||||
val matching = openTasks.filter { filter.matches(it, todayStart, todayEnd) }
|
||||
val ids = matching.mapTo(HashSet()) { it.taskId }
|
||||
FilterOverview(filter, matching.count { !it.isSubtask || it.parentId !in ids })
|
||||
}
|
||||
return ListsUiState.Content(
|
||||
smartCounts = smartCounts,
|
||||
filters = filters,
|
||||
groups = groups,
|
||||
todayDone = completedDueToday,
|
||||
todayTotal = todayTotal,
|
||||
upcoming = upcoming,
|
||||
allTasks = openTasks + completedTasks,
|
||||
allTasks = allTasks,
|
||||
tags = tags,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -215,7 +241,7 @@ class ListsViewModel @Inject constructor(
|
||||
// ⚠️ Server first. A row written before the MKCALENDAR would be a list
|
||||
// that exists on the phone and nowhere else, with nothing to say so.
|
||||
val outcome = runCatching { remoteLists.create(accountId, name.trim(), color) }
|
||||
.getOrElse { RemoteListRepository.Outcome.Unreachable }
|
||||
.getOrElse { RemoteLists.Outcome.Unreachable }
|
||||
_writeFailure.value = outcome.asFailure(ListWriteFailure.SAVE)
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,8 @@ import de.jeanlucmakiola.agendula.ui.detail.TaskDetailScreen
|
||||
import de.jeanlucmakiola.agendula.ui.detail.TaskDetailViewModel
|
||||
import de.jeanlucmakiola.agendula.ui.edit.TaskEditScreen
|
||||
import de.jeanlucmakiola.agendula.ui.edit.TaskEditViewModel
|
||||
import de.jeanlucmakiola.agendula.ui.filters.FilterEditScreen
|
||||
import de.jeanlucmakiola.agendula.ui.filters.FilterEditViewModel
|
||||
import de.jeanlucmakiola.agendula.ui.imports.ImportScreen
|
||||
import de.jeanlucmakiola.agendula.ui.lists.ListsScreen
|
||||
import de.jeanlucmakiola.agendula.ui.settings.SettingsScreen
|
||||
@@ -52,13 +54,17 @@ fun AgendulaNavHost(
|
||||
nav.navigate(Dest.Settings.build(Dest.Settings.SECTION_ACCOUNTS)) { launchSingleTop = true }
|
||||
is NavRequest.OpenAccount ->
|
||||
nav.navigate(Dest.Settings.buildAccount(navRequest.accountId)) { launchSingleTop = true }
|
||||
is NavRequest.NewTask -> nav.navigate(Dest.TaskEdit.buildNew(title = navRequest.title))
|
||||
is NavRequest.NewTask ->
|
||||
nav.navigate(Dest.TaskEdit.buildNew(presetListId = navRequest.listId, title = navRequest.title))
|
||||
is NavRequest.OpenSmart -> nav.navigate(Dest.TaskList.build(TaskFilter.Smart(navRequest.list))) {
|
||||
launchSingleTop = true
|
||||
}
|
||||
is NavRequest.OpenList -> nav.navigate(Dest.TaskList.build(TaskFilter.OfList(navRequest.listId))) {
|
||||
launchSingleTop = true
|
||||
}
|
||||
is NavRequest.OpenFilter -> nav.navigate(Dest.TaskList.build(TaskFilter.Saved(navRequest.filterId))) {
|
||||
launchSingleTop = true
|
||||
}
|
||||
is NavRequest.Import -> nav.navigate(Dest.Import.build(navRequest.uri))
|
||||
}
|
||||
onNavRequestConsumed()
|
||||
@@ -84,6 +90,7 @@ fun AgendulaNavHost(
|
||||
onOpenFilter = { filter -> nav.navigate(Dest.TaskList.build(filter)) },
|
||||
onOpenTask = { task -> nav.navigate(task.detailRoute()) },
|
||||
onNewTask = { nav.navigate(Dest.TaskEdit.buildNew()) },
|
||||
onNewFilter = { nav.navigate(Dest.FilterEdit.build()) },
|
||||
onOpenSettings = { nav.navigate(Dest.SETTINGS) },
|
||||
)
|
||||
}
|
||||
@@ -113,6 +120,8 @@ fun AgendulaNavHost(
|
||||
val filter = Dest.TaskList.filterOf(
|
||||
smart = args?.getString(Dest.TaskList.ARG_SMART),
|
||||
listId = args?.getLong(Dest.TaskList.ARG_LIST_ID) ?: Dest.NO_ID,
|
||||
tag = args?.getString(Dest.TaskList.ARG_TAG),
|
||||
filterId = args?.getLong(Dest.TaskList.ARG_FILTER_ID) ?: Dest.NO_ID,
|
||||
)
|
||||
val vm: TaskListViewModel = hiltViewModel()
|
||||
LaunchedEffect(filter) { vm.bind(filter) }
|
||||
@@ -123,9 +132,15 @@ fun AgendulaNavHost(
|
||||
viewModel = vm,
|
||||
onOpenTask = onOpenTask,
|
||||
onNewTask = {
|
||||
val presetListId = (filter as? TaskFilter.OfList)?.listId
|
||||
nav.navigate(Dest.TaskEdit.buildNew(presetListId = presetListId))
|
||||
nav.navigate(
|
||||
Dest.TaskEdit.buildNew(
|
||||
presetListId = (filter as? TaskFilter.OfList)?.listId,
|
||||
tag = (filter as? TaskFilter.Tagged)?.tag,
|
||||
filterId = (filter as? TaskFilter.Saved)?.id,
|
||||
),
|
||||
)
|
||||
},
|
||||
onEditFilter = { id -> nav.navigate(Dest.FilterEdit.build(id)) },
|
||||
onBack = { nav.popBackStack() },
|
||||
)
|
||||
}
|
||||
@@ -138,6 +153,32 @@ fun AgendulaNavHost(
|
||||
}
|
||||
}
|
||||
|
||||
composable(Dest.FilterEdit.route, arguments = Dest.FilterEdit.arguments) { entry ->
|
||||
val filterId = entry.arguments?.getLong(Dest.FilterEdit.ARG_FILTER_ID)?.takeIf { it != Dest.NO_ID }
|
||||
val vm: FilterEditViewModel = hiltViewModel()
|
||||
LaunchedEffect(filterId) { vm.bind(filterId) }
|
||||
|
||||
ReadableWidth {
|
||||
FilterEditScreen(
|
||||
viewModel = vm,
|
||||
isNew = filterId == null,
|
||||
onSaved = { id ->
|
||||
// A different id means the edited filter was gone and a new one was made.
|
||||
if (id != filterId) {
|
||||
// A new filter opens straight away, with the editor off the stack.
|
||||
nav.navigate(Dest.TaskList.build(TaskFilter.Saved(id))) {
|
||||
popUpTo(Dest.FilterEdit.route) { inclusive = true }
|
||||
}
|
||||
} else {
|
||||
nav.popBackStack()
|
||||
}
|
||||
},
|
||||
onDeleted = { nav.popBackStack(Dest.LISTS, inclusive = false) },
|
||||
onBack = { nav.popBackStack() },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
composable(Dest.TaskDetail.route, arguments = Dest.TaskDetail.arguments) { entry ->
|
||||
val taskId = entry.arguments?.getLong(Dest.TaskDetail.ARG_TASK_ID) ?: Dest.NO_ID
|
||||
val occurrence = entry.arguments?.getLong(Dest.TaskDetail.ARG_OCC)?.takeIf { it != Dest.NO_ID }
|
||||
@@ -165,6 +206,8 @@ fun AgendulaNavHost(
|
||||
val parentId = args?.getLong(Dest.TaskEdit.ARG_PARENT_ID) ?: Dest.NO_ID
|
||||
val occurrence = args?.getLong(Dest.TaskEdit.ARG_OCCURRENCE)?.takeIf { it != Dest.NO_ID }
|
||||
val title = args?.getString(Dest.TaskEdit.ARG_TITLE)
|
||||
val tag = args?.getString(Dest.TaskEdit.ARG_TAG)
|
||||
val filterId = args?.getLong(Dest.TaskEdit.ARG_FILTER_ID)?.takeIf { it != Dest.NO_ID }
|
||||
val vm: TaskEditViewModel = hiltViewModel()
|
||||
LaunchedEffect(taskId, presetListId, parentId) {
|
||||
if (taskId != Dest.NO_ID) {
|
||||
@@ -174,6 +217,8 @@ fun AgendulaNavHost(
|
||||
presetListId = presetListId.takeIf { it != Dest.NO_ID },
|
||||
parentId = parentId.takeIf { it != Dest.NO_ID },
|
||||
initialTitle = title,
|
||||
presetTag = tag,
|
||||
presetFilterId = filterId,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,28 +53,51 @@ object Dest {
|
||||
}
|
||||
|
||||
/**
|
||||
* One [TaskFilter]'s tasks. Carries *either* `listId` (a real list) *or*
|
||||
* `smart` (a [SmartList] name); whichever is set decides the filter.
|
||||
* One [TaskFilter]'s tasks. Carries one of `listId` (a real list), `smart`
|
||||
* (a [SmartList] name), `tag` or `filterId` (a saved filter); whichever is
|
||||
* set decides the filter.
|
||||
*/
|
||||
object TaskList {
|
||||
const val ARG_LIST_ID = "listId"
|
||||
const val ARG_SMART = "smart"
|
||||
const val route = "taskList?$ARG_LIST_ID={$ARG_LIST_ID}&$ARG_SMART={$ARG_SMART}"
|
||||
const val ARG_TAG = "tag"
|
||||
const val ARG_FILTER_ID = "filterId"
|
||||
const val route = "taskList?$ARG_LIST_ID={$ARG_LIST_ID}&$ARG_SMART={$ARG_SMART}&$ARG_TAG={$ARG_TAG}" +
|
||||
"&$ARG_FILTER_ID={$ARG_FILTER_ID}"
|
||||
|
||||
val arguments = listOf(
|
||||
navArgument(ARG_LIST_ID) { type = NavType.LongType; defaultValue = NO_ID },
|
||||
navArgument(ARG_SMART) { type = NavType.StringType; nullable = true; defaultValue = null },
|
||||
navArgument(ARG_TAG) { type = NavType.StringType; nullable = true; defaultValue = null },
|
||||
navArgument(ARG_FILTER_ID) { type = NavType.LongType; defaultValue = NO_ID },
|
||||
)
|
||||
|
||||
fun build(filter: TaskFilter): String = when (filter) {
|
||||
is TaskFilter.OfList -> "taskList?$ARG_LIST_ID=${filter.listId}"
|
||||
is TaskFilter.Smart -> "taskList?$ARG_SMART=${filter.list.name}"
|
||||
is TaskFilter.Tagged -> "taskList?$ARG_TAG=${android.net.Uri.encode(filter.tag)}"
|
||||
is TaskFilter.Saved -> "taskList?$ARG_FILTER_ID=${filter.id}"
|
||||
// Several lists at once only exists for the widget, which never routes here.
|
||||
is TaskFilter.OfLists -> error("OfLists has no task-list screen")
|
||||
}
|
||||
|
||||
/** Reconstruct the filter from a destination's arguments. */
|
||||
fun filterOf(smart: String?, listId: Long): TaskFilter =
|
||||
if (smart != null) TaskFilter.Smart(SmartList.valueOf(smart))
|
||||
else TaskFilter.OfList(listId)
|
||||
fun filterOf(smart: String?, listId: Long, tag: String? = null, filterId: Long = NO_ID): TaskFilter = when {
|
||||
smart != null -> TaskFilter.Smart(SmartList.valueOf(smart))
|
||||
tag != null -> TaskFilter.Tagged(tag)
|
||||
filterId != NO_ID -> TaskFilter.Saved(filterId)
|
||||
else -> TaskFilter.OfList(listId)
|
||||
}
|
||||
}
|
||||
|
||||
/** Creates a saved filter, or edits the one `filterId` names. */
|
||||
object FilterEdit {
|
||||
const val ARG_FILTER_ID = "filterId"
|
||||
const val route = "filterEdit?$ARG_FILTER_ID={$ARG_FILTER_ID}"
|
||||
val arguments = listOf(navArgument(ARG_FILTER_ID) { type = NavType.LongType; defaultValue = NO_ID })
|
||||
|
||||
fun build(filterId: Long? = null): String =
|
||||
if (filterId == null) "filterEdit" else "filterEdit?$ARG_FILTER_ID=$filterId"
|
||||
}
|
||||
|
||||
/** A single task's detail; `occ` (epoch millis) picks one occurrence of a series. */
|
||||
@@ -95,7 +118,8 @@ object Dest {
|
||||
|
||||
/**
|
||||
* The create / edit form. `taskId` set ([NO_ID] otherwise) ⇒ edit; for a new
|
||||
* task, `presetListId` / `parentId` optionally seed the form.
|
||||
* task, `presetListId` / `parentId` optionally seed the form, and `filterId`
|
||||
* fills it so the task lands in that saved filter.
|
||||
*/
|
||||
object TaskEdit {
|
||||
const val ARG_TASK_ID = "taskId"
|
||||
@@ -103,12 +127,16 @@ object Dest {
|
||||
const val ARG_PARENT_ID = "parentId"
|
||||
const val ARG_OCCURRENCE = "occ"
|
||||
const val ARG_TITLE = "title"
|
||||
const val ARG_TAG = "tag"
|
||||
const val ARG_FILTER_ID = "filterId"
|
||||
const val route =
|
||||
"taskEdit?$ARG_TASK_ID={$ARG_TASK_ID}" +
|
||||
"&$ARG_PRESET_LIST_ID={$ARG_PRESET_LIST_ID}" +
|
||||
"&$ARG_PARENT_ID={$ARG_PARENT_ID}" +
|
||||
"&$ARG_OCCURRENCE={$ARG_OCCURRENCE}" +
|
||||
"&$ARG_TITLE={$ARG_TITLE}"
|
||||
"&$ARG_TITLE={$ARG_TITLE}" +
|
||||
"&$ARG_TAG={$ARG_TAG}" +
|
||||
"&$ARG_FILTER_ID={$ARG_FILTER_ID}"
|
||||
|
||||
val arguments = listOf(
|
||||
navArgument(ARG_TASK_ID) { type = NavType.LongType; defaultValue = NO_ID },
|
||||
@@ -116,17 +144,27 @@ object Dest {
|
||||
navArgument(ARG_PARENT_ID) { type = NavType.LongType; defaultValue = NO_ID },
|
||||
navArgument(ARG_OCCURRENCE) { type = NavType.LongType; defaultValue = NO_ID },
|
||||
navArgument(ARG_TITLE) { type = NavType.StringType; nullable = true; defaultValue = null },
|
||||
navArgument(ARG_TAG) { type = NavType.StringType; nullable = true; defaultValue = null },
|
||||
navArgument(ARG_FILTER_ID) { type = NavType.LongType; defaultValue = NO_ID },
|
||||
)
|
||||
|
||||
fun buildEdit(taskId: Long, occurrenceStart: Long? = null): String =
|
||||
if (occurrenceStart == null) "taskEdit?$ARG_TASK_ID=$taskId"
|
||||
else "taskEdit?$ARG_TASK_ID=$taskId&$ARG_OCCURRENCE=$occurrenceStart"
|
||||
|
||||
fun buildNew(presetListId: Long? = null, parentId: Long? = null, title: String? = null): String {
|
||||
fun buildNew(
|
||||
presetListId: Long? = null,
|
||||
parentId: Long? = null,
|
||||
title: String? = null,
|
||||
tag: String? = null,
|
||||
filterId: Long? = null,
|
||||
): String {
|
||||
val params = buildList {
|
||||
if (presetListId != null) add("$ARG_PRESET_LIST_ID=$presetListId")
|
||||
if (parentId != null) add("$ARG_PARENT_ID=$parentId")
|
||||
if (!title.isNullOrBlank()) add("$ARG_TITLE=${android.net.Uri.encode(title)}")
|
||||
if (!tag.isNullOrBlank()) add("$ARG_TAG=${android.net.Uri.encode(tag)}")
|
||||
if (filterId != null) add("$ARG_FILTER_ID=$filterId")
|
||||
}
|
||||
return if (params.isEmpty()) "taskEdit" else "taskEdit?${params.joinToString("&")}"
|
||||
}
|
||||
|
||||
@@ -18,8 +18,11 @@ sealed interface NavRequest {
|
||||
/** One account's detail — where "sign in again" lives. */
|
||||
data class OpenAccount(val accountId: Long) : NavRequest
|
||||
|
||||
/** A new task, optionally titled with text shared from another app. */
|
||||
data class NewTask(val title: String? = null) : NavRequest
|
||||
/**
|
||||
* A new task, optionally titled with text shared from another app, or
|
||||
* preset to a list — a widget's own add button, when it shows one list.
|
||||
*/
|
||||
data class NewTask(val title: String? = null, val listId: Long? = null) : NavRequest
|
||||
|
||||
/** One of the smart lists — the launcher's "Today" shortcut, a widget header. */
|
||||
data class OpenSmart(val list: SmartList) : NavRequest
|
||||
@@ -27,6 +30,9 @@ sealed interface NavRequest {
|
||||
/** One real list — a widget header. */
|
||||
data class OpenList(val listId: Long) : NavRequest
|
||||
|
||||
/** One saved filter — a widget header. */
|
||||
data class OpenFilter(val filterId: Long) : NavRequest
|
||||
|
||||
/** An `.ics` another app handed over. */
|
||||
data class Import(val uri: Uri) : NavRequest
|
||||
}
|
||||
@@ -13,7 +13,6 @@ import androidx.compose.material3.Text
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
@@ -24,8 +23,6 @@ import androidx.compose.ui.unit.sp
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import de.jeanlucmakiola.agendula.R
|
||||
import de.jeanlucmakiola.agendula.ui.accounts.add.AddAccountScreen
|
||||
import de.jeanlucmakiola.agendula.ui.accounts.add.AddAccountViewModel
|
||||
import de.jeanlucmakiola.floret.components.OnboardingProgress
|
||||
import de.jeanlucmakiola.floret.components.OnboardingScaffold
|
||||
import de.jeanlucmakiola.floret.components.OnboardingSpace
|
||||
@@ -47,23 +44,7 @@ fun OnboardingFlow(
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
|
||||
if (state.step == OnboardingStep.Account) {
|
||||
// The same instance the wizard resolves for itself, so its length can be
|
||||
// read here without keeping a second copy of its state.
|
||||
val account: AddAccountViewModel = hiltViewModel()
|
||||
val accountState by account.state.collectAsStateWithLifecycle()
|
||||
// ⚠️ The wizard grows a step for a provider that needs an app password
|
||||
// minted first, and the outer bar has to grow with it — otherwise first
|
||||
// run counts one flow while the screen inside it counts another.
|
||||
LaunchedEffect(accountState.totalSteps) {
|
||||
viewModel.onAccountStepsChanged(accountState.totalSteps)
|
||||
}
|
||||
AddAccountScreen(
|
||||
onDone = { viewModel.onAccountFinished(added = true) },
|
||||
onBack = { viewModel.onAccountFinished(added = false) },
|
||||
stepOffset = state.position - 1,
|
||||
totalSteps = state.total,
|
||||
viewModel = account,
|
||||
)
|
||||
AccountStep(state, viewModel)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -5,10 +5,10 @@ import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import de.jeanlucmakiola.agendula.BuildConfig
|
||||
import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.agendula.data.tasks.TasksRepository
|
||||
import de.jeanlucmakiola.agendula.data.tasks.recoveringFromProviderFailure
|
||||
import de.jeanlucmakiola.agendula.ui.accounts.add.ADD_ACCOUNT_STEPS
|
||||
import de.jeanlucmakiola.agendula.ui.common.canScheduleExactAlarms
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
@@ -93,7 +93,7 @@ class OnboardingViewModel @Inject constructor(
|
||||
* provider is chosen, so the bar is honest from the first frame and stays
|
||||
* honest when the choice makes the flow longer.
|
||||
*/
|
||||
private val accountSlots = MutableStateFlow(ADD_ACCOUNT_STEPS)
|
||||
private val accountSlots = MutableStateFlow(ACCOUNT_STEP_SLOTS)
|
||||
|
||||
fun onAccountStepsChanged(count: Int) {
|
||||
accountSlots.value = count
|
||||
@@ -145,11 +145,11 @@ class OnboardingViewModel @Inject constructor(
|
||||
viewModelScope.launch {
|
||||
prefs.setRemindersEnabled(enabled)
|
||||
needsExactAlarms.value = enabled && !context.canScheduleExactAlarms()
|
||||
advanceTo(if (needsExactAlarms.value) OnboardingStep.ExactAlarms else OnboardingStep.SyncOffer)
|
||||
advanceTo(if (needsExactAlarms.value) OnboardingStep.ExactAlarms else afterReminders())
|
||||
}
|
||||
}
|
||||
|
||||
fun onExactAlarmsDone() = advanceTo(OnboardingStep.SyncOffer)
|
||||
fun onExactAlarmsDone() = advanceTo(afterReminders())
|
||||
|
||||
fun onSyncAnswered(connect: Boolean) {
|
||||
connectsAccount.value = connect
|
||||
@@ -192,11 +192,9 @@ class OnboardingViewModel @Inject constructor(
|
||||
step.value = when (step.value) {
|
||||
OnboardingStep.Reminders -> OnboardingStep.Welcome
|
||||
OnboardingStep.ExactAlarms -> OnboardingStep.Reminders
|
||||
// Once granted there is nothing left to ask, so back skips the step.
|
||||
OnboardingStep.SyncOffer ->
|
||||
if (needsExactAlarms.value && !context.canScheduleExactAlarms()) OnboardingStep.ExactAlarms
|
||||
else OnboardingStep.Reminders
|
||||
OnboardingStep.FirstList -> OnboardingStep.SyncOffer
|
||||
OnboardingStep.SyncOffer -> beforeSyncOffer()
|
||||
OnboardingStep.FirstList ->
|
||||
if (BuildConfig.SYNC_ENABLED) OnboardingStep.SyncOffer else beforeSyncOffer()
|
||||
else -> return
|
||||
}
|
||||
}
|
||||
@@ -230,6 +228,15 @@ class OnboardingViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
/** Without sync built in there is nothing to offer, and reminders lead straight on. */
|
||||
private fun afterReminders(): OnboardingStep =
|
||||
if (BuildConfig.SYNC_ENABLED) OnboardingStep.SyncOffer else OnboardingStep.FirstList
|
||||
|
||||
/** Once granted there is nothing left to ask, so back skips the exact-alarm step. */
|
||||
private fun beforeSyncOffer(): OnboardingStep =
|
||||
if (needsExactAlarms.value && !context.canScheduleExactAlarms()) OnboardingStep.ExactAlarms
|
||||
else OnboardingStep.Reminders
|
||||
|
||||
private fun afterFirstList() {
|
||||
step.value = stepAfterFirstList()
|
||||
}
|
||||
@@ -241,7 +248,7 @@ class OnboardingViewModel @Inject constructor(
|
||||
add(OnboardingStep.Welcome)
|
||||
add(OnboardingStep.Reminders)
|
||||
if (needsExact) add(OnboardingStep.ExactAlarms)
|
||||
add(OnboardingStep.SyncOffer)
|
||||
if (BuildConfig.SYNC_ENABLED) add(OnboardingStep.SyncOffer)
|
||||
if (sync) add(OnboardingStep.Account)
|
||||
if (needsList) add(OnboardingStep.FirstList)
|
||||
if (!sync) add(OnboardingStep.Backup)
|
||||
|
||||
@@ -5,6 +5,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.rounded.Checklist
|
||||
import androidx.compose.material.icons.rounded.CloudOff
|
||||
import androidx.compose.material.icons.rounded.CloudSync
|
||||
import androidx.compose.material.icons.rounded.NotificationsActive
|
||||
import androidx.compose.material3.Button
|
||||
@@ -14,6 +15,7 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import de.jeanlucmakiola.agendula.BuildConfig
|
||||
import de.jeanlucmakiola.agendula.R
|
||||
import de.jeanlucmakiola.floret.components.BenefitRow
|
||||
import de.jeanlucmakiola.floret.components.OnboardingSpace
|
||||
@@ -56,10 +58,18 @@ internal fun WelcomeStep(chrome: StepChrome, onContinue: () -> Unit) {
|
||||
body = stringResource(R.string.onboarding_welcome_reminders_body),
|
||||
)
|
||||
Spacer(Modifier.height(OnboardingSpace.sm))
|
||||
BenefitRow(
|
||||
icon = Icons.Rounded.CloudSync,
|
||||
title = stringResource(R.string.onboarding_welcome_sync_title),
|
||||
body = stringResource(R.string.onboarding_welcome_sync_body),
|
||||
)
|
||||
if (BuildConfig.SYNC_ENABLED) {
|
||||
BenefitRow(
|
||||
icon = Icons.Rounded.CloudSync,
|
||||
title = stringResource(R.string.onboarding_welcome_sync_title),
|
||||
body = stringResource(R.string.onboarding_welcome_sync_body),
|
||||
)
|
||||
} else {
|
||||
BenefitRow(
|
||||
icon = Icons.Rounded.CloudOff,
|
||||
title = stringResource(R.string.onboarding_welcome_offline_title),
|
||||
body = stringResource(R.string.onboarding_welcome_offline_body),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -40,13 +40,8 @@ import androidx.compose.ui.res.vectorResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import de.jeanlucmakiola.agendula.BuildConfig
|
||||
import de.jeanlucmakiola.agendula.R
|
||||
import de.jeanlucmakiola.agendula.ui.accounts.AccountDetailScreen
|
||||
import de.jeanlucmakiola.agendula.ui.accounts.AccountsScreen
|
||||
import de.jeanlucmakiola.agendula.ui.accounts.AccountsViewModel
|
||||
import de.jeanlucmakiola.agendula.data.tasks.room.AccountEntity
|
||||
import de.jeanlucmakiola.agendula.ui.accounts.add.AddAccountScreen
|
||||
import de.jeanlucmakiola.agendula.ui.accounts.add.AddAccountViewModel
|
||||
import de.jeanlucmakiola.agendula.ui.export.ExportScreen
|
||||
import de.jeanlucmakiola.agendula.ui.imports.ImportScreen
|
||||
import de.jeanlucmakiola.agendula.ui.licences.LicencesScreen
|
||||
@@ -64,7 +59,7 @@ import de.jeanlucmakiola.floret.crash.submitCrashReport
|
||||
import de.jeanlucmakiola.floret.locale.AppLanguage
|
||||
|
||||
/** The settings sub-screens reached from the hub's category rows. */
|
||||
private enum class SettingsSection {
|
||||
internal enum class SettingsSection {
|
||||
Appearance,
|
||||
TaskForm,
|
||||
Reminders,
|
||||
@@ -111,16 +106,6 @@ fun SettingsScreen(
|
||||
else SettingsSection.entries.firstOrNull { it.name == initialSection },
|
||||
)
|
||||
}
|
||||
// Hoisted so the add flow can refresh the list it returns to.
|
||||
val accountsViewModel: AccountsViewModel = hiltViewModel()
|
||||
// Shared with the add flow's own lookup, so "sign in again" can prefill it.
|
||||
val addAccountViewModel: AddAccountViewModel = hiltViewModel()
|
||||
val signInAgain: (AccountEntity) -> Unit = { account ->
|
||||
addAccountViewModel.startReauthentication(account.id, account.principalUrl, account.username)
|
||||
section = SettingsSection.AddAccount
|
||||
}
|
||||
// Which account the detail screen is showing; the section alone cannot say.
|
||||
var openAccount by rememberSaveable(initialAccountId) { mutableStateOf(initialAccountId) }
|
||||
|
||||
// Inside a sub-screen, system back (button or gesture) returns to the hub
|
||||
// rather than popping the whole Settings destination to the lists overview.
|
||||
@@ -159,58 +144,19 @@ fun SettingsScreen(
|
||||
SlideInSection(visible = section == SettingsSection.Import) {
|
||||
ImportScreen(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 = {
|
||||
// A sign-in abandoned half-way must not turn this into one.
|
||||
if (addAccountViewModel.state.value.reauthenticating) addAccountViewModel.onStartOver()
|
||||
section = SettingsSection.AddAccount
|
||||
},
|
||||
onSignInAgain = signInAgain,
|
||||
onOpenAccount = {
|
||||
openAccount = it
|
||||
section = SettingsSection.Account
|
||||
},
|
||||
onOpenStorage = { section = SettingsSection.Storage },
|
||||
onBack = { section = null },
|
||||
viewModel = accountsViewModel,
|
||||
)
|
||||
}
|
||||
SlideInSection(visible = section == SettingsSection.Licences) {
|
||||
LicencesScreen(onBack = { section = null })
|
||||
}
|
||||
SlideInSection(visible = section == SettingsSection.Account) {
|
||||
openAccount?.let { id ->
|
||||
AccountDetailScreen(
|
||||
accountId = id,
|
||||
onBack = { section = SettingsSection.Accounts },
|
||||
onRemoved = { section = SettingsSection.Accounts },
|
||||
onSignInAgain = signInAgain,
|
||||
onOpenStorage = { section = SettingsSection.Storage },
|
||||
viewModel = accountsViewModel,
|
||||
)
|
||||
}
|
||||
}
|
||||
SlideInSection(visible = section == SettingsSection.AddAccount) {
|
||||
AddAccountScreen(
|
||||
// The list stays composed underneath, so nothing re-runs its
|
||||
// init and OnResume never fires on a section change. It no longer
|
||||
// needs to: the accounts come from an observed query, so a new
|
||||
// account — and every later sync — arrives on its own.
|
||||
onDone = { section = SettingsSection.Accounts },
|
||||
onBack = { section = SettingsSection.Accounts },
|
||||
viewModel = addAccountViewModel,
|
||||
)
|
||||
}
|
||||
AccountSections(
|
||||
section = section,
|
||||
initialAccountId = initialAccountId,
|
||||
onSection = { section = it },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SlideInSection(visible: Boolean, content: @Composable () -> Unit) {
|
||||
internal fun SlideInSection(visible: Boolean, content: @Composable () -> Unit) {
|
||||
AnimatedVisibility(
|
||||
visible = visible,
|
||||
enter = slideInHorizontally { it } + fadeIn(),
|
||||
@@ -267,17 +213,19 @@ private fun SettingsHub(
|
||||
|
||||
Spacer(Modifier.height(8.dp))
|
||||
GroupedSectionHeader(stringResource(R.string.settings_group_data))
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_section_accounts),
|
||||
summary = stringResource(R.string.settings_accounts_subtitle),
|
||||
position = Position.Top,
|
||||
leading = { CategoryIcon(Icons.Rounded.CloudSync, ChipAccent.Tertiary) },
|
||||
onClick = { onOpenSection(SettingsSection.Accounts) },
|
||||
)
|
||||
if (BuildConfig.SYNC_ENABLED) {
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_section_accounts),
|
||||
summary = stringResource(R.string.settings_accounts_subtitle),
|
||||
position = Position.Top,
|
||||
leading = { CategoryIcon(Icons.Rounded.CloudSync, ChipAccent.Tertiary) },
|
||||
onClick = { onOpenSection(SettingsSection.Accounts) },
|
||||
)
|
||||
}
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_section_storage),
|
||||
summary = stringResource(R.string.settings_storage_subtitle),
|
||||
position = Position.Bottom,
|
||||
position = if (BuildConfig.SYNC_ENABLED) Position.Bottom else Position.Alone,
|
||||
leading = { CategoryIcon(Icons.Rounded.Storage, ChipAccent.Secondary) },
|
||||
onClick = { onOpenSection(SettingsSection.Storage) },
|
||||
)
|
||||
|
||||
Loaded 100 of 219 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user