Compare commits

..
1 Commits
Author SHA1 Message Date
makiolaj b018b6a92b feat: pure black theme for AMOLED screens (#99) 2026-10-28 12:01:15 +01:00
133 changed files with 342 additions and 4141 deletions

No files matched your search

-34
View File
@@ -7,40 +7,6 @@ All notable changes to this project are documented here. The format follows
## [Unreleased] ## [Unreleased]
## [1.3.0] - 2026-11-02
### Added
- Self-hosted servers with a self-signed certificate can be added: Agendula
shows the certificate's fingerprint and asks whether to trust it. A server
reached over plain http can be added too, after a warning. If a trusted
certificate is later renewed, "Sign in again" on the account asks about the
new one (#68).
- An account can be given its own name (#105).
- Pull down on the task list to sync (#73).
- Task list sections can be collapsed (#91).
- A pure black theme for AMOLED screens (#99).
- Agendula can show up as "Tasks" in your launcher, picked from launcher
previews as in Calendula (#104).
- Reminder notifications show the task's description (#94).
### Changed
- Adding or opening a task from a widget returns you to the home screen
afterwards (#93).
- A swipe that starts at the screen edge no longer swipes the task (#90).
- Today also lists tasks that start today, and keeps the ones completed today
in a Completed section (#123).
### Fixed
- A task created from Today, or from a Today widget, is due today, so it shows
up there (#122).
- Completing a task from the list, a widget or another device now cancels its
upcoming reminder and clears one already showing (#127, #129).
- The splash screen follows the app's theme, including Black and dynamic
colour, instead of flashing white in dark mode (#119).
- A task saved without its default start or due reminder because it had
already rung (a start of "now", say) gets it once that date is moved later,
if the task has no reminder from that date (#121).
## [1.2.0] - 2026-10-26 ## [1.2.0] - 2026-10-26
### Added ### Added
+2 -2
View File
@@ -33,8 +33,8 @@ android {
// (1.0.x: 1.0.0 -> 10000; from 1.1.0: 1.1.0-beta.1 -> 1010001, // (1.0.x: 1.0.0 -> 10000; from 1.1.0: 1.1.0-beta.1 -> 1010001,
// 1.1.0 -> 1010099), and CI fails if the committed one doesn't match. // 1.1.0 -> 1010099), and CI fails if the committed one doesn't match.
// See docs/RELEASING.md. // See docs/RELEASING.md.
versionCode = 1030099 versionCode = 1020099
versionName = "1.3.0" versionName = "1.2.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
@@ -1,553 +0,0 @@
{
"formatVersion": 1,
"database": {
"version": 4,
"identityHash": "47f3bedbfbf4ddd1db32864c718de83a",
"entities": [
{
"tableName": "accounts",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `display_name` TEXT NOT NULL, `principal_url` TEXT, `home_set_url` TEXT, `username` TEXT, `last_sync_at` INTEGER, `last_sync_error` TEXT, `label` TEXT)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "displayName",
"columnName": "display_name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "principalUrl",
"columnName": "principal_url",
"affinity": "TEXT"
},
{
"fieldPath": "homeSetUrl",
"columnName": "home_set_url",
"affinity": "TEXT"
},
{
"fieldPath": "username",
"columnName": "username",
"affinity": "TEXT"
},
{
"fieldPath": "lastSyncAt",
"columnName": "last_sync_at",
"affinity": "INTEGER"
},
{
"fieldPath": "lastSyncError",
"columnName": "last_sync_error",
"affinity": "TEXT"
},
{
"fieldPath": "label",
"columnName": "label",
"affinity": "TEXT"
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
}
},
{
"tableName": "task_lists",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `color` INTEGER NOT NULL, `account_id` INTEGER, `is_visible` INTEGER NOT NULL DEFAULT 1, `is_synced` INTEGER NOT NULL DEFAULT 1, `owner` TEXT, `is_read_only` INTEGER NOT NULL DEFAULT 0, `sort_order` INTEGER NOT NULL DEFAULT 0, `href` TEXT, `ctag` TEXT, `sync_token` TEXT, `is_dirty` INTEGER NOT NULL DEFAULT 0, FOREIGN KEY(`account_id`) REFERENCES `accounts`(`id`) ON UPDATE NO ACTION ON DELETE SET NULL )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "color",
"columnName": "color",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "accountId",
"columnName": "account_id",
"affinity": "INTEGER"
},
{
"fieldPath": "isVisible",
"columnName": "is_visible",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "1"
},
{
"fieldPath": "isSynced",
"columnName": "is_synced",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "1"
},
{
"fieldPath": "owner",
"columnName": "owner",
"affinity": "TEXT"
},
{
"fieldPath": "isReadOnly",
"columnName": "is_read_only",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "sortOrder",
"columnName": "sort_order",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "href",
"columnName": "href",
"affinity": "TEXT"
},
{
"fieldPath": "ctag",
"columnName": "ctag",
"affinity": "TEXT"
},
{
"fieldPath": "syncToken",
"columnName": "sync_token",
"affinity": "TEXT"
},
{
"fieldPath": "isDirty",
"columnName": "is_dirty",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_task_lists_account_id",
"unique": false,
"columnNames": [
"account_id"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_task_lists_account_id` ON `${TABLE_NAME}` (`account_id`)"
}
],
"foreignKeys": [
{
"table": "accounts",
"onDelete": "SET NULL",
"onUpdate": "NO ACTION",
"columns": [
"account_id"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "tasks",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `list_id` INTEGER NOT NULL, `uid` TEXT NOT NULL, `href` TEXT, `etag` TEXT, `title` TEXT, `description` TEXT, `location` TEXT, `url` TEXT, `color` INTEGER, `categories` TEXT NOT NULL DEFAULT '', `status` INTEGER NOT NULL DEFAULT 0, `percent_complete` INTEGER, `completed_at` INTEGER, `priority` INTEGER NOT NULL DEFAULT 0, `classification` INTEGER, `dtstart` INTEGER, `due` INTEGER, `duration` TEXT, `is_all_day` INTEGER NOT NULL DEFAULT 0, `timezone` TEXT, `rrule` TEXT, `rdate` TEXT, `exdate` TEXT, `recurrence_id` INTEGER, `master_id` INTEGER, `parent_id` INTEGER, `sort_order` INTEGER NOT NULL DEFAULT 0, `created_at` INTEGER, `last_modified` INTEGER, `sequence` INTEGER NOT NULL DEFAULT 0, `is_dirty` INTEGER NOT NULL DEFAULT 0, `is_deleted` INTEGER NOT NULL DEFAULT 0, `unknown_properties` TEXT, `reminders_off` INTEGER NOT NULL DEFAULT 0, FOREIGN KEY(`list_id`) REFERENCES `task_lists`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`master_id`) REFERENCES `tasks`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`parent_id`) REFERENCES `tasks`(`id`) ON UPDATE NO ACTION ON DELETE SET NULL )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "listId",
"columnName": "list_id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "uid",
"columnName": "uid",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "href",
"columnName": "href",
"affinity": "TEXT"
},
{
"fieldPath": "etag",
"columnName": "etag",
"affinity": "TEXT"
},
{
"fieldPath": "title",
"columnName": "title",
"affinity": "TEXT"
},
{
"fieldPath": "description",
"columnName": "description",
"affinity": "TEXT"
},
{
"fieldPath": "location",
"columnName": "location",
"affinity": "TEXT"
},
{
"fieldPath": "url",
"columnName": "url",
"affinity": "TEXT"
},
{
"fieldPath": "color",
"columnName": "color",
"affinity": "INTEGER"
},
{
"fieldPath": "categories",
"columnName": "categories",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "''"
},
{
"fieldPath": "status",
"columnName": "status",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "percentComplete",
"columnName": "percent_complete",
"affinity": "INTEGER"
},
{
"fieldPath": "completedAt",
"columnName": "completed_at",
"affinity": "INTEGER"
},
{
"fieldPath": "priority",
"columnName": "priority",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "classification",
"columnName": "classification",
"affinity": "INTEGER"
},
{
"fieldPath": "dtstart",
"columnName": "dtstart",
"affinity": "INTEGER"
},
{
"fieldPath": "due",
"columnName": "due",
"affinity": "INTEGER"
},
{
"fieldPath": "duration",
"columnName": "duration",
"affinity": "TEXT"
},
{
"fieldPath": "isAllDay",
"columnName": "is_all_day",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "timezone",
"columnName": "timezone",
"affinity": "TEXT"
},
{
"fieldPath": "rrule",
"columnName": "rrule",
"affinity": "TEXT"
},
{
"fieldPath": "rdate",
"columnName": "rdate",
"affinity": "TEXT"
},
{
"fieldPath": "exdate",
"columnName": "exdate",
"affinity": "TEXT"
},
{
"fieldPath": "recurrenceId",
"columnName": "recurrence_id",
"affinity": "INTEGER"
},
{
"fieldPath": "masterId",
"columnName": "master_id",
"affinity": "INTEGER"
},
{
"fieldPath": "parentId",
"columnName": "parent_id",
"affinity": "INTEGER"
},
{
"fieldPath": "sortOrder",
"columnName": "sort_order",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "createdAt",
"columnName": "created_at",
"affinity": "INTEGER"
},
{
"fieldPath": "lastModified",
"columnName": "last_modified",
"affinity": "INTEGER"
},
{
"fieldPath": "sequence",
"columnName": "sequence",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "isDirty",
"columnName": "is_dirty",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "isDeleted",
"columnName": "is_deleted",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "unknownProperties",
"columnName": "unknown_properties",
"affinity": "TEXT"
},
{
"fieldPath": "remindersOff",
"columnName": "reminders_off",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_tasks_list_id_is_deleted",
"unique": false,
"columnNames": [
"list_id",
"is_deleted"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_tasks_list_id_is_deleted` ON `${TABLE_NAME}` (`list_id`, `is_deleted`)"
},
{
"name": "index_tasks_parent_id",
"unique": false,
"columnNames": [
"parent_id"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_tasks_parent_id` ON `${TABLE_NAME}` (`parent_id`)"
},
{
"name": "index_tasks_master_id_recurrence_id",
"unique": false,
"columnNames": [
"master_id",
"recurrence_id"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_tasks_master_id_recurrence_id` ON `${TABLE_NAME}` (`master_id`, `recurrence_id`)"
},
{
"name": "index_tasks_is_dirty",
"unique": false,
"columnNames": [
"is_dirty"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_tasks_is_dirty` ON `${TABLE_NAME}` (`is_dirty`)"
},
{
"name": "index_tasks_list_id_uid_recurrence_id",
"unique": true,
"columnNames": [
"list_id",
"uid",
"recurrence_id"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_tasks_list_id_uid_recurrence_id` ON `${TABLE_NAME}` (`list_id`, `uid`, `recurrence_id`)"
}
],
"foreignKeys": [
{
"table": "task_lists",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"list_id"
],
"referencedColumns": [
"id"
]
},
{
"table": "tasks",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"master_id"
],
"referencedColumns": [
"id"
]
},
{
"table": "tasks",
"onDelete": "SET NULL",
"onUpdate": "NO ACTION",
"columns": [
"parent_id"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "task_alarms",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `task_id` INTEGER NOT NULL, `minutes_before` INTEGER NOT NULL, `reference` TEXT NOT NULL DEFAULT 'DUE', `message` TEXT, `uid` TEXT NOT NULL DEFAULT '', `raw` TEXT, FOREIGN KEY(`task_id`) REFERENCES `tasks`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "taskId",
"columnName": "task_id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "minutesBefore",
"columnName": "minutes_before",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "reference",
"columnName": "reference",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "'DUE'"
},
{
"fieldPath": "message",
"columnName": "message",
"affinity": "TEXT"
},
{
"fieldPath": "uid",
"columnName": "uid",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "''"
},
{
"fieldPath": "raw",
"columnName": "raw",
"affinity": "TEXT"
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_task_alarms_task_id",
"unique": false,
"columnNames": [
"task_id"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_task_alarms_task_id` ON `${TABLE_NAME}` (`task_id`)"
}
],
"foreignKeys": [
{
"table": "tasks",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"task_id"
],
"referencedColumns": [
"id"
]
}
]
}
],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '47f3bedbfbf4ddd1db32864c718de83a')"
]
}
}
@@ -147,21 +147,6 @@ class TasksDatabaseMigrationTest {
} }
} }
@Test
fun addsAnEmptyAccountLabel() {
helper.createDatabase(TEST_DB, 3).use { db ->
db.execSQL("INSERT INTO accounts (id, display_name) VALUES (1, 'me@example.org')")
}
helper.runMigrationsAndValidate(TEST_DB, 4, true, TasksDatabase.MIGRATION_3_4).use { db ->
db.query("SELECT display_name, label FROM accounts").use { c ->
assertThat(c.moveToFirst()).isTrue()
assertThat(c.getString(0)).isEqualTo("me@example.org")
assertThat(c.isNull(1)).isTrue()
}
}
}
private companion object { private companion object {
const val TEST_DB = "migration-test.db" const val TEST_DB = "migration-test.db"
} }
@@ -13,13 +13,10 @@ import dagger.hilt.components.SingletonComponent
import dagger.multibindings.IntoSet import dagger.multibindings.IntoSet
import de.jeanlucmakiola.agendula.data.sync.AccountCreator import de.jeanlucmakiola.agendula.data.sync.AccountCreator
import de.jeanlucmakiola.agendula.data.sync.AccountRepository import de.jeanlucmakiola.agendula.data.sync.AccountRepository
import de.jeanlucmakiola.agendula.data.sync.AccountSyncRequests
import de.jeanlucmakiola.agendula.data.sync.CalDavGateway import de.jeanlucmakiola.agendula.data.sync.CalDavGateway
import de.jeanlucmakiola.agendula.data.sync.LoginFlowRecord import de.jeanlucmakiola.agendula.data.sync.LoginFlowRecord
import de.jeanlucmakiola.agendula.data.sync.ManualSync
import de.jeanlucmakiola.agendula.data.sync.OkHttpCalDavGateway import de.jeanlucmakiola.agendula.data.sync.OkHttpCalDavGateway
import de.jeanlucmakiola.agendula.data.sync.PendingLoginFlowStore import de.jeanlucmakiola.agendula.data.sync.PendingLoginFlowStore
import de.jeanlucmakiola.agendula.data.sync.PullSync
import de.jeanlucmakiola.agendula.data.sync.RemoteListRepository import de.jeanlucmakiola.agendula.data.sync.RemoteListRepository
import de.jeanlucmakiola.agendula.data.sync.RemoteLists import de.jeanlucmakiola.agendula.data.sync.RemoteLists
import de.jeanlucmakiola.agendula.data.sync.SyncNoticeNotifier import de.jeanlucmakiola.agendula.data.sync.SyncNoticeNotifier
@@ -82,11 +79,7 @@ abstract class SyncBindModule {
@Binds @Binds
@Singleton @Singleton
abstract fun bindSyncRequests(impl: AccountSyncRequests): SyncRequests abstract fun bindSyncRequests(impl: SyncTrigger): SyncRequests
@Binds
@Singleton
abstract fun bindManualSync(impl: PullSync): ManualSync
@Binds @Binds
@IntoSet @IntoSet
@@ -7,7 +7,6 @@ import de.jeanlucmakiola.agendula.data.tasks.room.AccountEntity
import de.jeanlucmakiola.agendula.data.tasks.room.TaskListEntity import de.jeanlucmakiola.agendula.data.tasks.room.TaskListEntity
import de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase import de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase
import de.jeanlucmakiola.caldav.CalDavDiscovery import de.jeanlucmakiola.caldav.CalDavDiscovery
import de.jeanlucmakiola.caldav.ServerTrust
import de.jeanlucmakiola.caldav.TaskCollection import de.jeanlucmakiola.caldav.TaskCollection
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
@@ -17,7 +16,6 @@ import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import okhttp3.HttpUrl import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import java.io.IOException
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@@ -37,12 +35,7 @@ interface AccountCreator {
selected: Set<TaskCollection>, selected: Set<TaskCollection>,
/** An existing account to sign in again, instead of creating one. */ /** An existing account to sign in again, instead of creating one. */
reauthenticating: Long? = null, reauthenticating: Long? = null,
/** What the user agreed to trust on the way here; saved with the account. */
trust: ServerTrust = ServerTrust.DEFAULT,
): AccountRepository.Outcome ): AccountRepository.Outcome
/** What [accountId] was set up trusting, so signing in again starts from it. */
suspend fun trustFor(accountId: Long): ServerTrust
} }
/** /**
@@ -66,7 +59,6 @@ class AccountRepository @Inject constructor(
private val quarantine: QuarantineStore, private val quarantine: QuarantineStore,
private val notices: SyncNoticeStore, private val notices: SyncNoticeStore,
private val collectionSupport: CollectionSupportStore, private val collectionSupport: CollectionSupportStore,
private val serverTrust: ServerTrustStore,
private val gateway: CalDavGateway, private val gateway: CalDavGateway,
private val availability: SyncAvailability, private val availability: SyncAvailability,
private val settings: SettingsPrefs, private val settings: SettingsPrefs,
@@ -105,11 +97,6 @@ class AccountRepository @Inject constructor(
/** The accounts, observed, so a sync landing updates a screen that is open. */ /** The accounts, observed, so a sync landing updates a screen that is open. */
fun observeAll(): Flow<List<AccountEntity>> = database.accounts().observeAll() fun observeAll(): Flow<List<AccountEntity>> = database.accounts().observeAll()
/** Names the account on screen; blank goes back to the default (#105). */
suspend fun setLabel(accountId: Long, label: String) = withContext(io) {
database.accounts().setLabel(accountId, label.trim().ifEmpty { null })
}
/** Every account's synced lists. */ /** Every account's synced lists. */
fun observeSyncedLists(): Flow<List<TaskListEntity>> = database.taskLists().observeSynced() fun observeSyncedLists(): Flow<List<TaskListEntity>> = database.taskLists().observeSynced()
@@ -128,7 +115,6 @@ class AccountRepository @Inject constructor(
found: CalDavDiscovery.Outcome.Found, found: CalDavDiscovery.Outcome.Found,
selected: Set<TaskCollection>, selected: Set<TaskCollection>,
reauthenticating: Long?, reauthenticating: Long?,
trust: ServerTrust,
): Outcome = withContext(io) { ): Outcome = withContext(io) {
if (!availability.accountsUsable()) return@withContext Outcome.ExternalStorage if (!availability.accountsUsable()) return@withContext Outcome.ExternalStorage
// "Sign in again" names its account, so a login name the server spells // "Sign in again" names its account, so a login name the server spells
@@ -136,7 +122,7 @@ class AccountRepository @Inject constructor(
// Only on the same server: anything else is a different account. // Only on the same server: anything else is a different account.
val target = reauthenticating?.let { database.accounts().account(it) } val target = reauthenticating?.let { database.accounts().account(it) }
if (target != null && sameServer(target, found)) { if (target != null && sameServer(target, found)) {
return@withContext reauthenticate(target, target.displayName, username, appPassword, found, selected, trust) return@withContext reauthenticate(target, target.displayName, username, appPassword, found, selected)
} }
// Both stores, not just one. There is no unique index on // Both stores, not just one. There is no unique index on
// accounts.display_name and nothing prunes Room when the system account // accounts.display_name and nothing prunes Room when the system account
@@ -153,7 +139,7 @@ class AccountRepository @Inject constructor(
// same name is still a duplicate, so this can never silently overwrite a // same name is still a duplicate, so this can never silently overwrite a
// working credential. // working credential.
if (existing != null && accountState.needsSignIn(existing.id)) { if (existing != null && accountState.needsSignIn(existing.id)) {
return@withContext reauthenticate(existing, displayName, username, appPassword, found, selected, trust) return@withContext reauthenticate(existing, displayName, username, appPassword, found, selected)
} }
if (existing != null) return@withContext Outcome.AlreadyExists if (existing != null) return@withContext Outcome.AlreadyExists
@@ -194,15 +180,6 @@ class AccountRepository @Inject constructor(
inserted += attach(id, selected) inserted += attach(id, selected)
id id
} }
// Inside the rollback's reach: a DataStore failure here would otherwise
// leave the row with no credential, the hazard the comment above names.
if (!saveTrust(accountId, trust)) {
rollback(accountId, inserted)
return@withContext Outcome.CredentialFailed(
Outcome.Cause.NOT_SAVED,
"the server trust could not be stored",
)
}
if (!credentials.put(accountId, appPassword)) { if (!credentials.put(accountId, appPassword)) {
// Never leave a half-made account behind: without a credential it // Never leave a half-made account behind: without a credential it
@@ -230,8 +207,6 @@ class AccountRepository @Inject constructor(
} }
} }
override suspend fun trustFor(accountId: Long): ServerTrust = serverTrust.get(accountId)
/** /**
* Points [selected] at [accountId], re-attaching what a previous removal * Points [selected] at [accountId], re-attaching what a previous removal
* left behind rather than inserting a second copy. * left behind rather than inserting a second copy.
@@ -323,7 +298,6 @@ class AccountRepository @Inject constructor(
val outcome = gateway.discover( val outcome = gateway.discover(
principal.toString(), principal.toString(),
CalDavGateway.Credentials(username, password, principal), CalDavGateway.Credentials(username, password, principal),
serverTrust.get(accountId),
) )
) { ) {
is CalDavDiscovery.Outcome.Found -> Collections.Found(outcome.collections, attached) is CalDavDiscovery.Outcome.Found -> Collections.Found(outcome.collections, attached)
@@ -332,10 +306,6 @@ class AccountRepository @Inject constructor(
-> Collections.NeedsSignIn -> Collections.NeedsSignIn
is CalDavDiscovery.Outcome.NotCalDav -> Collections.Failed(outcome.cause) is CalDavDiscovery.Outcome.NotCalDav -> Collections.Failed(outcome.cause)
is CalDavDiscovery.Outcome.Failed -> Collections.Failed(outcome.cause) is CalDavDiscovery.Outcome.Failed -> Collections.Failed(outcome.cause)
// A certificate that changed since it was trusted. The account offers
// "Sign in again" for a certificate failure, and that flow re-asks.
is CalDavDiscovery.Outcome.UntrustedCertificate ->
Collections.Failed(CalDavDiscovery.Outcome.Cause.TLS)
} }
} }
@@ -416,7 +386,6 @@ class AccountRepository @Inject constructor(
appPassword: String, appPassword: String,
found: CalDavDiscovery.Outcome.Found, found: CalDavDiscovery.Outcome.Found,
selected: Set<TaskCollection>, selected: Set<TaskCollection>,
trust: ServerTrust,
): Outcome { ): Outcome {
val accountId = existing.id val accountId = existing.id
if (!credentials.put(accountId, appPassword)) { if (!credentials.put(accountId, appPassword)) {
@@ -425,11 +394,6 @@ class AccountRepository @Inject constructor(
"the device keystore would not store the password", "the device keystore would not store the password",
) )
} }
// Before anything else changes, so a failure leaves the account stopped
// and the sign-in retryable rather than signed in with the old trust.
if (!withContext(NonCancellable) { saveTrust(accountId, trust) }) {
return Outcome.CredentialFailed(Outcome.Cause.NOT_SAVED, "the server trust could not be stored")
}
withContext(NonCancellable) { withContext(NonCancellable) {
database.runInTransaction { database.runInTransaction {
database.accounts().update( database.accounts().update(
@@ -473,23 +437,11 @@ class AccountRepository @Inject constructor(
* and holds the user's tasks; `SET NULL` returns it to being device-only, * and holds the user's tasks; `SET NULL` returns it to being device-only,
* which is exactly where it came from. * which is exactly where it came from.
*/ */
private suspend fun rollback(accountId: Long, inserted: List<Long>) { private fun rollback(accountId: Long, inserted: List<Long>) = database.runInTransaction {
database.runInTransaction { inserted.forEach { database.taskLists().delete(it) }
inserted.forEach { database.taskLists().delete(it) } database.accounts().delete(accountId)
database.accounts().delete(accountId)
}
// Ids are never reused, and a later create overwrites the keys anyway.
runCatching { serverTrust.forget(accountId) }
} }
private suspend fun saveTrust(accountId: Long, trust: ServerTrust): Boolean =
try {
serverTrust.put(accountId, trust)
true
} catch (_: IOException) {
false
}
/** /**
* Puts every existing account back on the periodic schedule. * Puts every existing account back on the periodic schedule.
* *
@@ -578,7 +530,6 @@ class AccountRepository @Inject constructor(
// The same reasoning, for what the server said it would let us // The same reasoning, for what the server said it would let us
// create: keyed by an id nothing will ever mention again. // create: keyed by an id nothing will ever mention again.
collectionSupport.forget(accountId) collectionSupport.forget(accountId)
serverTrust.forget(accountId)
credentials.clear(accountId) credentials.clear(accountId)
database.accounts().delete(accountId) database.accounts().delete(accountId)
accounts.find(displayName)?.let { accounts.remove(it) } accounts.find(displayName)?.let { accounts.remove(it) }
@@ -606,7 +557,6 @@ class AccountRepository @Inject constructor(
try { try {
gateway.revokeAppPassword( gateway.revokeAppPassword(
CalDavGateway.Credentials(username, password, origin), CalDavGateway.Credentials(username, password, origin),
serverTrust.get(accountId),
) )
} catch (_: CancellationException) { } catch (_: CancellationException) {
// Deliberately swallowed. If the caller went away mid-revoke we still // Deliberately swallowed. If the caller went away mid-revoke we still
@@ -1,20 +0,0 @@
package de.jeanlucmakiola.agendula.data.sync
import de.jeanlucmakiola.agendula.data.di.IoDispatcher
import de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.withContext
import javax.inject.Inject
/** [SyncRequests] by account id; [SyncTrigger] keys on the account's stable display name. */
class AccountSyncRequests @Inject constructor(
private val database: TasksDatabase,
private val trigger: SyncTrigger,
@IoDispatcher private val io: CoroutineDispatcher,
) : SyncRequests {
override suspend fun syncNow(accountId: Long) {
val name = withContext(io) { database.accounts().account(accountId)?.displayName } ?: return
trigger.enqueue(name)
}
}
@@ -5,7 +5,6 @@ import de.jeanlucmakiola.caldav.AppPassword
import de.jeanlucmakiola.caldav.CalDavDiscovery import de.jeanlucmakiola.caldav.CalDavDiscovery
import de.jeanlucmakiola.caldav.CalDavHttp import de.jeanlucmakiola.caldav.CalDavHttp
import de.jeanlucmakiola.caldav.NextcloudLoginFlow import de.jeanlucmakiola.caldav.NextcloudLoginFlow
import de.jeanlucmakiola.caldav.ServerTrust
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.job import kotlinx.coroutines.job
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@@ -26,22 +25,12 @@ import javax.inject.Singleton
interface CalDavGateway { interface CalDavGateway {
/** Discovery against [target], optionally carrying credentials. */ /** Discovery against [target], optionally carrying credentials. */
suspend fun discover( suspend fun discover(target: String, credentials: Credentials? = null): CalDavDiscovery.Outcome
target: String,
credentials: Credentials? = null,
trust: ServerTrust = ServerTrust.DEFAULT,
): CalDavDiscovery.Outcome
/** Starts Nextcloud Login Flow v2, or returns null if this is not a Nextcloud. */ /** Starts Nextcloud Login Flow v2, or returns null if this is not a Nextcloud. */
suspend fun startLoginFlow( suspend fun startLoginFlow(server: HttpUrl): NextcloudLoginFlow.Flow?
server: HttpUrl,
trust: ServerTrust = ServerTrust.DEFAULT,
): NextcloudLoginFlow.Flow?
suspend fun pollLoginFlow( suspend fun pollLoginFlow(flow: NextcloudLoginFlow.Flow): NextcloudLoginFlow.PollResult
flow: NextcloudLoginFlow.Flow,
trust: ServerTrust = ServerTrust.DEFAULT,
): NextcloudLoginFlow.PollResult
/** /**
* Hands an app password back to the server, best effort. * Hands an app password back to the server, best effort.
@@ -49,10 +38,7 @@ interface CalDavGateway {
* Without it, uninstalling never revokes anything — the credential we minted * Without it, uninstalling never revokes anything — the credential we minted
* outlives the app in the user's device list. * outlives the app in the user's device list.
*/ */
suspend fun revokeAppPassword( suspend fun revokeAppPassword(credentials: Credentials): Boolean
credentials: Credentials,
trust: ServerTrust = ServerTrust.DEFAULT,
): Boolean
/** /**
* The same, for a password the login flow just minted. * The same, for a password the login flow just minted.
@@ -63,10 +49,7 @@ interface CalDavGateway {
* principal, and a subpath install's `https://host/nextcloud/` would collapse * principal, and a subpath install's `https://host/nextcloud/` would collapse
* to `https://host/` — a DELETE that 404s on every one of them. * to `https://host/` — a DELETE that 404s on every one of them.
*/ */
suspend fun revokeIssuedAppPassword( suspend fun revokeIssuedAppPassword(credentials: Credentials): Boolean
credentials: Credentials,
trust: ServerTrust = ServerTrust.DEFAULT,
): Boolean
/** Credentials, and the origin whose registrable domain they are scoped to. */ /** Credentials, and the origin whose registrable domain they are scoped to. */
data class Credentials(val username: String, val password: String, val origin: HttpUrl) data class Credentials(val username: String, val password: String, val origin: HttpUrl)
@@ -88,19 +71,17 @@ class OkHttpCalDavGateway @Inject constructor(
override suspend fun discover( override suspend fun discover(
target: String, target: String,
credentials: CalDavGateway.Credentials?, credentials: CalDavGateway.Credentials?,
trust: ServerTrust,
): CalDavDiscovery.Outcome = withContext(io) { ): CalDavDiscovery.Outcome = withContext(io) {
val client = credentials?.let { val client = credentials?.let {
CalDavHttp.authenticated(userAgent, it.username, it.password, it.origin, trust) CalDavHttp.authenticated(userAgent, it.username, it.password, it.origin)
} ?: CalDavHttp.anonymous(userAgent, trust) } ?: CalDavHttp.anonymous(userAgent)
val job = coroutineContext.job val job = coroutineContext.job
CalDavDiscovery(client, SystemDnsResolver { !job.isActive }, allowCleartext = trust.allowCleartext) CalDavDiscovery(client, SystemDnsResolver { !job.isActive }).discover(target)
.discover(target)
} }
override suspend fun startLoginFlow(server: HttpUrl, trust: ServerTrust): NextcloudLoginFlow.Flow? = override suspend fun startLoginFlow(server: HttpUrl): NextcloudLoginFlow.Flow? =
withContext(io) { withContext(io) {
NextcloudLoginFlow(CalDavHttp.anonymous(userAgent, trust), userAgent) NextcloudLoginFlow(CalDavHttp.anonymous(userAgent), userAgent)
.start(server, now()) .start(server, now())
.getOrNull() .getOrNull()
} }
@@ -112,12 +93,9 @@ class OkHttpCalDavGateway @Inject constructor(
* the shared client's ceiling is sized for a multiget, not for a two-second * 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. * poll loop against a server that answered a moment ago.
*/ */
override suspend fun pollLoginFlow( override suspend fun pollLoginFlow(flow: NextcloudLoginFlow.Flow): NextcloudLoginFlow.PollResult =
flow: NextcloudLoginFlow.Flow,
trust: ServerTrust,
): NextcloudLoginFlow.PollResult =
withContext(io) { withContext(io) {
val client = CalDavHttp.anonymous(userAgent, trust).newBuilder() val client = CalDavHttp.anonymous(userAgent).newBuilder()
.callTimeout(POLL_TIMEOUT_SECONDS, TimeUnit.SECONDS) .callTimeout(POLL_TIMEOUT_SECONDS, TimeUnit.SECONDS)
.build() .build()
NextcloudLoginFlow(client, userAgent).poll(flow, now()) NextcloudLoginFlow(client, userAgent).poll(flow, now())
@@ -125,20 +103,18 @@ class OkHttpCalDavGateway @Inject constructor(
override suspend fun revokeAppPassword( override suspend fun revokeAppPassword(
credentials: CalDavGateway.Credentials, credentials: CalDavGateway.Credentials,
trust: ServerTrust,
): Boolean = withContext(io) { ): Boolean = withContext(io) {
val client = CalDavHttp.authenticated( val client = CalDavHttp.authenticated(
userAgent, credentials.username, credentials.password, credentials.origin, trust, userAgent, credentials.username, credentials.password, credentials.origin,
) )
AppPassword.revoke(client, credentials.origin) AppPassword.revoke(client, credentials.origin)
} }
override suspend fun revokeIssuedAppPassword( override suspend fun revokeIssuedAppPassword(
credentials: CalDavGateway.Credentials, credentials: CalDavGateway.Credentials,
trust: ServerTrust,
): Boolean = withContext(io) { ): Boolean = withContext(io) {
val client = CalDavHttp.authenticated( val client = CalDavHttp.authenticated(
userAgent, credentials.username, credentials.password, credentials.origin, trust, userAgent, credentials.username, credentials.password, credentials.origin,
) )
AppPassword.revokeAt(client, credentials.origin) AppPassword.revokeAt(client, credentials.origin)
} }
@@ -7,7 +7,6 @@ import androidx.datastore.preferences.core.longPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey
import de.jeanlucmakiola.agendula.data.di.SyncStateDataStore import de.jeanlucmakiola.agendula.data.di.SyncStateDataStore
import de.jeanlucmakiola.caldav.NextcloudLoginFlow import de.jeanlucmakiola.caldav.NextcloudLoginFlow
import de.jeanlucmakiola.caldav.ServerTrust
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
@@ -24,8 +23,8 @@ import javax.inject.Singleton
*/ */
interface LoginFlowRecord { interface LoginFlowRecord {
/** Called **before** the browser is handed the URL; [trust] is what reaching it took. */ /** Called **before** the browser is handed the URL. */
suspend fun remember(flow: NextcloudLoginFlow.Flow, trust: ServerTrust = ServerTrust.DEFAULT) suspend fun remember(flow: NextcloudLoginFlow.Flow)
/** The flow is over, however it ended. */ /** The flow is over, however it ended. */
suspend fun forget() suspend fun forget()
@@ -59,13 +58,12 @@ class PendingLoginFlowStore @Inject constructor(
private var reclaimed = false private var reclaimed = false
/** Records [flow] so a process that dies mid-approval can still finish with it. */ /** Records [flow] so a process that dies mid-approval can still finish with it. */
override suspend fun remember(flow: NextcloudLoginFlow.Flow, trust: ServerTrust) { override suspend fun remember(flow: NextcloudLoginFlow.Flow) {
dataStore.edit { prefs -> dataStore.edit { prefs ->
prefs[LOGIN_URL] = flow.loginUrl.toString() prefs[LOGIN_URL] = flow.loginUrl.toString()
prefs[POLL_ENDPOINT] = flow.pollEndpoint.toString() prefs[POLL_ENDPOINT] = flow.pollEndpoint.toString()
prefs[POLL_TOKEN] = flow.pollToken prefs[POLL_TOKEN] = flow.pollToken
prefs[DEADLINE] = flow.deadlineEpochSeconds prefs[DEADLINE] = flow.deadlineEpochSeconds
TRUST.write(prefs, trust)
} }
} }
@@ -76,7 +74,6 @@ class PendingLoginFlowStore @Inject constructor(
prefs.remove(POLL_ENDPOINT) prefs.remove(POLL_ENDPOINT)
prefs.remove(POLL_TOKEN) prefs.remove(POLL_TOKEN)
prefs.remove(DEADLINE) prefs.remove(DEADLINE)
TRUST.remove(prefs)
} }
} }
@@ -101,8 +98,7 @@ class PendingLoginFlowStore @Inject constructor(
reclaimed = true reclaimed = true
} }
val flow = pending() ?: return val flow = pending() ?: return
val trust = trust() when (val result = gateway.pollLoginFlow(flow)) {
when (val result = gateway.pollLoginFlow(flow, trust)) {
is NextcloudLoginFlow.PollResult.Approved -> { is NextcloudLoginFlow.PollResult.Approved -> {
// Cleared first: a revocation that fails must not leave a token // Cleared first: a revocation that fails must not leave a token
// that would be polled again, and the 200 is already spent. // that would be polled again, and the 200 is already spent.
@@ -113,7 +109,6 @@ class PendingLoginFlowStore @Inject constructor(
password = result.credentials.appPassword, password = result.credentials.appPassword,
origin = result.credentials.server, origin = result.credentials.server,
), ),
trust,
) )
} }
@@ -141,13 +136,10 @@ class PendingLoginFlowStore @Inject constructor(
) )
} }
private suspend fun trust(): ServerTrust = TRUST.read(dataStore.data.first())
private companion object { private companion object {
val LOGIN_URL = stringPreferencesKey("login_flow_url") val LOGIN_URL = stringPreferencesKey("login_flow_url")
val POLL_ENDPOINT = stringPreferencesKey("login_flow_poll_endpoint") val POLL_ENDPOINT = stringPreferencesKey("login_flow_poll_endpoint")
val POLL_TOKEN = stringPreferencesKey("login_flow_poll_token") val POLL_TOKEN = stringPreferencesKey("login_flow_poll_token")
val DEADLINE = longPreferencesKey("login_flow_deadline") val DEADLINE = longPreferencesKey("login_flow_deadline")
val TRUST = ServerTrustKeys("login_flow_trusted_certificates", "login_flow_cleartext")
} }
} }
@@ -1,76 +0,0 @@
package de.jeanlucmakiola.agendula.data.sync
import android.content.Context
import androidx.work.WorkInfo
import androidx.work.WorkManager
import dagger.hilt.android.qualifiers.ApplicationContext
import de.jeanlucmakiola.agendula.data.di.ApplicationScope
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeoutOrNull
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Duration.Companion.seconds
/**
* Pull to sync over [SyncTrigger]: an expedited sync per account, and
* [syncing] held until they finish.
*
* A sync waiting on its network constraint never starts, so if none is running
* after [START_GRACE] the indicator lets go; the work stays queued and runs
* once the device is back online.
*/
@Singleton
class PullSync @Inject constructor(
@ApplicationContext private val context: Context,
private val accounts: AccountRepository,
private val trigger: SyncTrigger,
availability: SyncAvailability,
@ApplicationScope private val scope: CoroutineScope,
) : ManualSync {
override val available: Flow<Boolean> =
combine(availability.observe(), accounts.observeAll()) { usable, all -> usable && all.isNotEmpty() }
private val _syncing = MutableStateFlow(false)
override val syncing: StateFlow<Boolean> = _syncing
override fun syncAll() {
if (_syncing.value) return
_syncing.value = true
scope.launch {
try {
val names = accounts.syncable().map { trigger.enqueue(it.displayName, expedited = true) }
if (names.isNotEmpty()) awaitSyncs(names)
} finally {
_syncing.value = false
}
}
}
private suspend fun awaitSyncs(names: List<String>) {
val workManager = WorkManager.getInstance(context)
val states = combine(names.map { workManager.getWorkInfosForUniqueWorkFlow(it) }) { infos ->
infos.flatMap { it }.map { it.state }
}
withTimeoutOrNull(MAX_WAIT) {
val started = withTimeoutOrNull(START_GRACE) {
states.first { all -> WorkInfo.State.RUNNING in all || all.all { it.isFinished } }
} ?: return@withTimeoutOrNull
if (started.all { it.isFinished }) return@withTimeoutOrNull
states.map { all -> WorkInfo.State.RUNNING in all }.first { running -> !running }
}
}
private companion object {
val START_GRACE = 3.seconds
val MAX_WAIT = 2.minutes
}
}
@@ -14,7 +14,6 @@ import de.jeanlucmakiola.caldav.CollectionOutcome
import de.jeanlucmakiola.caldav.CollectionSupport import de.jeanlucmakiola.caldav.CollectionSupport
import de.jeanlucmakiola.caldav.DavCollectionAdmin import de.jeanlucmakiola.caldav.DavCollectionAdmin
import de.jeanlucmakiola.caldav.ResourceNames import de.jeanlucmakiola.caldav.ResourceNames
import de.jeanlucmakiola.caldav.ServerTrust
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.async import kotlinx.coroutines.async
@@ -54,7 +53,6 @@ class RemoteListRepository @Inject constructor(
private val accountState: AccountStateStore, private val accountState: AccountStateStore,
private val resolver: ProviderResolver, private val resolver: ProviderResolver,
private val push: PushStore, private val push: PushStore,
private val trust: ServerTrustStore,
@IoDispatcher private val io: CoroutineDispatcher, @IoDispatcher private val io: CoroutineDispatcher,
) : RemoteLists { ) : RemoteLists {
@@ -254,11 +252,11 @@ class RemoteListRepository @Inject constructor(
val origin = account.principalUrl?.toHttpUrlOrNull() ?: return null val origin = account.principalUrl?.toHttpUrlOrNull() ?: return null
val password = (credentials.get(account.id) as? CredentialStore.Secret.Present)?.value val password = (credentials.get(account.id) as? CredentialStore.Secret.Present)?.value
?: return null ?: return null
return DavCollectionAdmin(client(username, password, origin, trust.get(account.id))) return DavCollectionAdmin(client(username, password, origin))
} }
private fun client(username: String, password: String, origin: HttpUrl, trust: ServerTrust): OkHttpClient = private fun client(username: String, password: String, origin: HttpUrl): OkHttpClient =
CalDavHttp.authenticated(USER_AGENT, username, password, origin, trust) CalDavHttp.authenticated(USER_AGENT, username, password, origin)
private suspend fun forgetPerListState(listId: Long) { private suspend fun forgetPerListState(listId: Long) {
val ids = setOf(listId) val ids = setOf(listId)
@@ -1,44 +0,0 @@
package de.jeanlucmakiola.agendula.data.sync
import androidx.datastore.preferences.core.MutablePreferences
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.stringSetPreferencesKey
import androidx.lifecycle.SavedStateHandle
import de.jeanlucmakiola.caldav.ServerTrust
/**
* The one mapping between a [ServerTrust] and the key-value stores it is kept
* in, so a field added to it cannot be dropped by one of them.
*/
class ServerTrustKeys(private val certificatesName: String, private val cleartextName: String) {
private val certificates = stringSetPreferencesKey(certificatesName)
private val cleartext = booleanPreferencesKey(cleartextName)
fun read(prefs: Preferences) = ServerTrust(
pinnedCertificates = prefs[certificates].orEmpty(),
allowCleartext = prefs[cleartext] ?: false,
)
fun write(prefs: MutablePreferences, trust: ServerTrust) {
if (trust.pinnedCertificates.isEmpty()) {
prefs.remove(certificates)
} else {
prefs[certificates] = trust.pinnedCertificates
}
if (trust.allowCleartext) prefs[cleartext] = true else prefs.remove(cleartext)
}
fun remove(prefs: MutablePreferences) = write(prefs, ServerTrust.DEFAULT)
fun read(handle: SavedStateHandle) = ServerTrust(
pinnedCertificates = handle.get<ArrayList<String>>(certificatesName).orEmpty().toSet(),
allowCleartext = handle[cleartextName] ?: false,
)
fun write(handle: SavedStateHandle, trust: ServerTrust) {
handle[certificatesName] = ArrayList(trust.pinnedCertificates)
handle[cleartextName] = trust.allowCleartext
}
}
@@ -1,38 +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 de.jeanlucmakiola.agendula.data.di.SyncStateDataStore
import de.jeanlucmakiola.caldav.ServerTrust
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import javax.inject.Inject
import javax.inject.Singleton
/** What each account's user agreed to trust: pinned certificates and plain http. */
@Singleton
class ServerTrustStore @Inject constructor(
@SyncStateDataStore private val dataStore: DataStore<Preferences>,
) {
suspend fun get(accountId: Long): ServerTrust = read(dataStore.data.first(), accountId)
fun observe(accountId: Long): Flow<ServerTrust> =
dataStore.data.map { read(it, accountId) }.distinctUntilChanged()
suspend fun put(accountId: Long, trust: ServerTrust) {
dataStore.edit { keys(accountId).write(it, trust) }
}
suspend fun forget(accountId: Long) {
dataStore.edit { keys(accountId).remove(it) }
}
private fun read(prefs: Preferences, accountId: Long) = keys(accountId).read(prefs)
private fun keys(accountId: Long) =
ServerTrustKeys("server_trust_certs_$accountId", "server_trust_cleartext_$accountId")
}
@@ -33,7 +33,6 @@ class SyncEngine @Inject constructor(
private val notices: SyncNoticeStore, private val notices: SyncNoticeStore,
private val availability: SyncAvailability, private val availability: SyncAvailability,
private val push: PushRegistrar, private val push: PushRegistrar,
private val trust: ServerTrustStore,
@IoDispatcher private val io: CoroutineDispatcher, @IoDispatcher private val io: CoroutineDispatcher,
) { ) {
@@ -91,7 +90,7 @@ class SyncEngine @Inject constructor(
} }
} }
val client = CalDavHttp.authenticated(USER_AGENT, username, password, origin, trust.get(account.id)) val client = CalDavHttp.authenticated(USER_AGENT, username, password, origin)
val subscriptions = push.subscriptionsByHref(account.id) val subscriptions = push.subscriptionsByHref(account.id)
val reports = syncCollections(account) { url -> val reports = syncCollections(account) { url ->
CalendarCollection(client, url, pushRegistration = subscriptions[url.toString()]) CalendarCollection(client, url, pushRegistration = subscriptions[url.toString()])
@@ -49,7 +49,6 @@ data class SyncFailure(val kind: Kind, val httpCode: Int? = null) {
private val TLS = listOf( private val TLS = listOf(
"SSLHandshakeException", "SSLHandshakeException",
"SSLPeerUnverifiedException", "SSLPeerUnverifiedException",
"UntrustedHostException",
"CertPathValidatorException", "CertPathValidatorException",
"CertificateException", "CertificateException",
"SSLException", "SSLException",
@@ -35,7 +35,11 @@ import kotlin.time.Duration.Companion.seconds
@Singleton @Singleton
class SyncTrigger @Inject constructor( class SyncTrigger @Inject constructor(
@ApplicationContext private val context: Context, @ApplicationContext private val context: Context,
) { ) : SyncRequests {
override fun syncNow(accountName: String) {
enqueue(accountName)
}
/** /**
* Starts a sync now. * Starts a sync now.
@@ -56,8 +56,8 @@ class SyncWorker @AssistedInject constructor(
// log line is the same as saying nothing. Only what is new: the // log line is the same as saying nothing. Only what is new: the
// store has already dropped whatever the user has been told. // store has already dropped whatever the user has been told.
noticeNotifier.post(accountName, outcome.notices) noticeNotifier.post(accountName, outcome.notices)
// Here rather than left to ReminderRefresher's debounce: once // Nothing else re-arms reminders for what a sync pulled in: in our
// the worker returns, nothing keeps the process alive for it. // own store no provider broadcast fires.
if (outcome.reports.any { it.hadWork || it.discardedEdits.isNotEmpty() }) { if (outcome.reports.any { it.hadWork || it.discardedEdits.isNotEmpty() }) {
runCatching { reminderScheduler.sync() } runCatching { reminderScheduler.sync() }
} }
@@ -12,7 +12,6 @@ import dagger.hilt.android.qualifiers.ApplicationContext
import de.jeanlucmakiola.agendula.data.di.IoDispatcher import de.jeanlucmakiola.agendula.data.di.IoDispatcher
import de.jeanlucmakiola.agendula.data.sync.AccountStateStore import de.jeanlucmakiola.agendula.data.sync.AccountStateStore
import de.jeanlucmakiola.agendula.data.sync.CredentialStore import de.jeanlucmakiola.agendula.data.sync.CredentialStore
import de.jeanlucmakiola.agendula.data.sync.ServerTrustStore
import de.jeanlucmakiola.agendula.data.sync.SyncAvailability import de.jeanlucmakiola.agendula.data.sync.SyncAvailability
import de.jeanlucmakiola.agendula.data.sync.SyncReport import de.jeanlucmakiola.agendula.data.sync.SyncReport
import de.jeanlucmakiola.agendula.data.tasks.room.AccountEntity import de.jeanlucmakiola.agendula.data.tasks.room.AccountEntity
@@ -49,7 +48,6 @@ class PushRegistrar @Inject constructor(
private val credentials: CredentialStore, private val credentials: CredentialStore,
private val accountState: AccountStateStore, private val accountState: AccountStateStore,
private val availability: SyncAvailability, private val availability: SyncAvailability,
private val trust: ServerTrustStore,
@IoDispatcher private val io: CoroutineDispatcher, @IoDispatcher private val io: CoroutineDispatcher,
) { ) {
@@ -233,7 +231,7 @@ class PushRegistrar @Inject constructor(
val username = account.username ?: return null val username = account.username ?: return null
val origin = account.principalUrl?.toHttpUrlOrNull() ?: return null val origin = account.principalUrl?.toHttpUrlOrNull() ?: return null
val password = (credentials.get(account.id) as? CredentialStore.Secret.Present)?.value ?: return null val password = (credentials.get(account.id) as? CredentialStore.Secret.Present)?.value ?: return null
return CalDavHttp.authenticated(USER_AGENT, username, password, origin, trust.get(account.id)) return CalDavHttp.authenticated(USER_AGENT, username, password, origin)
.newBuilder() .newBuilder()
.callTimeout(CALL_TIMEOUT_SECONDS, TimeUnit.SECONDS) .callTimeout(CALL_TIMEOUT_SECONDS, TimeUnit.SECONDS)
.build() .build()
@@ -15,12 +15,9 @@ import androidx.compose.material.icons.rounded.Check
import androidx.compose.material.icons.rounded.Bolt import androidx.compose.material.icons.rounded.Bolt
import androidx.compose.material.icons.rounded.CloudSync import androidx.compose.material.icons.rounded.CloudSync
import androidx.compose.material.icons.rounded.DeleteOutline import androidx.compose.material.icons.rounded.DeleteOutline
import androidx.compose.material.icons.rounded.Edit
import androidx.compose.material.icons.rounded.GppMaybe
import androidx.compose.material.icons.rounded.History import androidx.compose.material.icons.rounded.History
import androidx.compose.material.icons.rounded.HistoryToggleOff import androidx.compose.material.icons.rounded.HistoryToggleOff
import androidx.compose.material.icons.rounded.Inventory2 import androidx.compose.material.icons.rounded.Inventory2
import androidx.compose.material.icons.rounded.LockOpen
import androidx.compose.material.icons.rounded.Refresh import androidx.compose.material.icons.rounded.Refresh
import androidx.compose.material.icons.rounded.SyncProblem import androidx.compose.material.icons.rounded.SyncProblem
import androidx.compose.material3.Button import androidx.compose.material3.Button
@@ -29,7 +26,6 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@@ -50,13 +46,9 @@ import de.jeanlucmakiola.agendula.data.sync.DiscardedEdit
import de.jeanlucmakiola.agendula.data.sync.SyncNotice import de.jeanlucmakiola.agendula.data.sync.SyncNotice
import de.jeanlucmakiola.agendula.data.tasks.room.AccountEntity import de.jeanlucmakiola.agendula.data.tasks.room.AccountEntity
import de.jeanlucmakiola.agendula.ui.accounts.add.message import de.jeanlucmakiola.agendula.ui.accounts.add.message
import de.jeanlucmakiola.agendula.ui.common.NameField
import de.jeanlucmakiola.agendula.ui.common.OnResume import de.jeanlucmakiola.agendula.ui.common.OnResume
import de.jeanlucmakiola.caldav.ServerCertificate
import de.jeanlucmakiola.caldav.ServerTrust
import de.jeanlucmakiola.floret.components.CollapsingScaffold import de.jeanlucmakiola.floret.components.CollapsingScaffold
import de.jeanlucmakiola.floret.components.FullScreenPicker import de.jeanlucmakiola.floret.components.FullScreenPicker
import de.jeanlucmakiola.floret.components.GroupedHint
import de.jeanlucmakiola.floret.components.GroupedListInset import de.jeanlucmakiola.floret.components.GroupedListInset
import de.jeanlucmakiola.floret.components.GroupedRow import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.Position import de.jeanlucmakiola.floret.components.Position
@@ -83,8 +75,6 @@ internal fun AccountDetailScreen(
val accounts by viewModel.accounts.collectAsStateWithLifecycle() val accounts by viewModel.accounts.collectAsStateWithLifecycle()
val usable by viewModel.accountsUsable.collectAsStateWithLifecycle() val usable by viewModel.accountsUsable.collectAsStateWithLifecycle()
val push by viewModel.push.collectAsStateWithLifecycle() val push by viewModel.push.collectAsStateWithLifecycle()
val trust by remember(accountId) { viewModel.trust(accountId) }
.collectAsStateWithLifecycle(ServerTrust.DEFAULT)
OnResume(viewModel::refreshPush) OnResume(viewModel::refreshPush)
val row = accounts?.firstOrNull { it.account.id == accountId } val row = accounts?.firstOrNull { it.account.id == accountId }
@@ -100,34 +90,12 @@ internal fun AccountDetailScreen(
// The confirmation sheet. The removal it starts outlives this screen, and is // The confirmation sheet. The removal it starts outlives this screen, and is
// shown on the accounts list rather than here. // shown on the accounts list rather than here.
var confirming by remember { mutableStateOf(false) } var confirming by remember { mutableStateOf(false) }
var renaming by remember { mutableStateOf(false) }
if (renaming) {
AccountNamePicker(
current = account.label.orEmpty(),
defaultTitle = account.defaultTitle(),
onSave = { name ->
viewModel.rename(account, name)
renaming = false
},
onDismiss = { renaming = false },
)
}
CollapsingScaffold(title = identity.title, onBack = onBack, predictiveBack = true, visible = visible) { CollapsingScaffold(title = identity.title, onBack = onBack, predictiveBack = true, visible = visible) {
AccountHero(identity) AccountHero(identity)
Spacer(Modifier.height(24.dp)) Spacer(Modifier.height(24.dp))
val actions = buildList<@Composable (Position) -> Unit> { val actions = buildList<@Composable (Position) -> Unit> {
add { position ->
GroupedRow(
title = stringResource(R.string.accounts_name),
summary = identity.title,
position = position,
leading = { Icon(Icons.Rounded.Edit, contentDescription = null) },
onClick = { renaming = true },
)
}
add { position -> add { position ->
GroupedRow( GroupedRow(
title = stringResource(R.string.accounts_last_sync), title = stringResource(R.string.accounts_last_sync),
@@ -136,28 +104,6 @@ internal fun AccountDetailScreen(
leading = { Icon(Icons.Rounded.History, contentDescription = null) }, leading = { Icon(Icons.Rounded.History, contentDescription = null) },
) )
} }
if (trust.allowCleartext) {
add { position ->
GroupedRow(
title = stringResource(R.string.accounts_connection),
summary = stringResource(R.string.accounts_connection_cleartext),
position = position,
leading = { Icon(Icons.Rounded.LockOpen, contentDescription = null) },
)
}
}
if (trust.pinnedCertificates.isNotEmpty()) {
add { position ->
GroupedRow(
title = stringResource(R.string.accounts_trusted_certificate),
summary = trust.pinnedCertificates.sorted().joinToString("\n") {
ServerCertificate.formatFingerprint(it.take(PINNED_PREFIX_CHARS)) + ":\u2026"
},
position = position,
leading = { Icon(Icons.Rounded.GppMaybe, contentDescription = null) },
)
}
}
if (!usable) return@buildList if (!usable) return@buildList
push?.let { pushState -> push?.let { pushState ->
add { position -> add { position ->
@@ -169,7 +115,7 @@ internal fun AccountDetailScreen(
) )
} }
} }
if (shown.offersSignIn) { if (shown.needsSignIn) {
add { position -> add { position ->
GroupedRow( GroupedRow(
title = stringResource(R.string.accounts_sign_in_again), title = stringResource(R.string.accounts_sign_in_again),
@@ -178,8 +124,7 @@ internal fun AccountDetailScreen(
onClick = { onSignInAgain(account) }, onClick = { onSignInAgain(account) },
) )
} }
} } else {
if (!shown.needsSignIn) {
add { position -> add { position ->
GroupedRow( GroupedRow(
title = stringResource(R.string.accounts_sync_now), title = stringResource(R.string.accounts_sync_now),
@@ -535,33 +480,3 @@ private fun errorTitle(text: String): AnnotatedString {
buildAnnotatedString { withStyle(SpanStyle(color = error)) { append(text) } } buildAnnotatedString { withStyle(SpanStyle(color = error)) { append(text) } }
} }
} }
/** Names an account on screen (#105); left empty, it goes back to [defaultTitle]. */
@Composable
private fun AccountNamePicker(
current: String,
defaultTitle: String,
onSave: (String) -> Unit,
onDismiss: () -> Unit,
) {
var name by remember { mutableStateOf(current) }
FullScreenPicker(
title = stringResource(R.string.accounts_name),
onDismiss = onDismiss,
actions = {
TextButton(onClick = { onSave(name) }) { Text(stringResource(R.string.save)) }
},
) {
NameField(
name = name,
placeholder = defaultTitle,
autoFocus = true,
onNameChange = { name = it },
onImeAction = { onSave(name) },
)
GroupedHint(stringResource(R.string.accounts_name_hint, defaultTitle))
}
}
/** The first eight bytes of a pinned fingerprint, enough to compare at a glance. */
private const val PINNED_PREFIX_CHARS = 16
@@ -44,14 +44,6 @@ internal data class AccountIdentity(
val user: String?, val user: String?,
) )
/** The title an account has without a [AccountEntity.label]. */
internal fun AccountEntity.defaultTitle(): String {
val url = principalUrl?.toHttpUrlOrNull()
val provider = url?.let { CalDavProvider.forPrincipal(it) }
val host = url?.host?.removePrefix("www.")
return if (provider?.hosted == true) provider.label else host ?: displayName
}
internal fun AccountEntity.identity(): AccountIdentity { internal fun AccountEntity.identity(): AccountIdentity {
val url = principalUrl?.toHttpUrlOrNull() val url = principalUrl?.toHttpUrlOrNull()
val provider = url?.let { CalDavProvider.forPrincipal(it) } val provider = url?.let { CalDavProvider.forPrincipal(it) }
@@ -61,7 +53,7 @@ internal fun AccountEntity.identity(): AccountIdentity {
provider = provider, provider = provider,
// The fallback is the stored name: an account with no principal URL // The fallback is the stored name: an account with no principal URL
// never got past discovery, so there is nothing better to call it. // never got past discovery, so there is nothing better to call it.
title = label ?: defaultTitle(), title = if (hosted) provider.label else host ?: displayName,
secondary = if (hosted) host else provider?.label, secondary = if (hosted) host else provider?.label,
user = username?.takeIf { it.isNotBlank() }, user = username?.takeIf { it.isNotBlank() },
) )
@@ -119,7 +119,7 @@ internal fun AccountsScreen(
when { when {
row.removing -> CircularProgressIndicator(Modifier.size(20.dp)) row.removing -> CircularProgressIndicator(Modifier.size(20.dp))
!usable -> Unit !usable -> Unit
row.offersSignIn -> IconButton(onClick = { onSignInAgain(account) }) { row.needsSignIn -> IconButton(onClick = { onSignInAgain(account) }) {
Icon( Icon(
Icons.AutoMirrored.Rounded.Login, Icons.AutoMirrored.Rounded.Login,
contentDescription = stringResource(R.string.accounts_sign_in_again), contentDescription = stringResource(R.string.accounts_sign_in_again),
@@ -8,9 +8,7 @@ import de.jeanlucmakiola.agendula.data.prefs.DEFAULT_SYNC_INTERVAL_MINUTES
import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.agendula.data.sync.AccountRepository import de.jeanlucmakiola.agendula.data.sync.AccountRepository
import de.jeanlucmakiola.agendula.data.sync.AccountStateStore import de.jeanlucmakiola.agendula.data.sync.AccountStateStore
import de.jeanlucmakiola.agendula.data.sync.ServerTrustStore
import de.jeanlucmakiola.agendula.data.sync.SyncAvailability import de.jeanlucmakiola.agendula.data.sync.SyncAvailability
import de.jeanlucmakiola.agendula.data.sync.SyncFailure
import de.jeanlucmakiola.agendula.data.sync.SyncNotice import de.jeanlucmakiola.agendula.data.sync.SyncNotice
import de.jeanlucmakiola.agendula.data.sync.SyncNoticeStore import de.jeanlucmakiola.agendula.data.sync.SyncNoticeStore
import de.jeanlucmakiola.agendula.data.sync.SyncTrigger import de.jeanlucmakiola.agendula.data.sync.SyncTrigger
@@ -32,7 +30,6 @@ import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import de.jeanlucmakiola.caldav.CalDavDiscovery import de.jeanlucmakiola.caldav.CalDavDiscovery
import de.jeanlucmakiola.caldav.ServerTrust
import de.jeanlucmakiola.caldav.TaskCollection import de.jeanlucmakiola.caldav.TaskCollection
import okhttp3.HttpUrl import okhttp3.HttpUrl
import javax.inject.Inject import javax.inject.Inject
@@ -46,7 +43,6 @@ class AccountsViewModel @Inject constructor(
private val settings: SettingsPrefs, private val settings: SettingsPrefs,
private val distributors: PushDistributors, private val distributors: PushDistributors,
private val pushRegistrar: PushRegistrar, private val pushRegistrar: PushRegistrar,
private val serverTrust: ServerTrustStore,
pushStore: PushStore, pushStore: PushStore,
availability: SyncAvailability, availability: SyncAvailability,
@IoDispatcher io: CoroutineDispatcher, @IoDispatcher io: CoroutineDispatcher,
@@ -192,18 +188,7 @@ class AccountsViewModel @Inject constructor(
*/ */
val notices: List<SyncNotice> = emptyList(), val notices: List<SyncNotice> = emptyList(),
val lists: ListPush = ListPush.NONE, val lists: ListPush = ListPush.NONE,
) { )
/**
* The last sync was refused over the server's certificate — typically a
* pinned one that was renewed. Nothing sets [needsSignIn] for that, yet
* signing in again is the only route that can trust the new one.
*/
val certificateRefused: Boolean
get() = account.lastSyncError?.let { SyncFailure.of(it).kind == SyncFailure.Kind.CERTIFICATE } == true
/** Whether "Sign in again" is the way forward for this account. */
val offersSignIn: Boolean get() = needsSignIn || certificateRefused
}
/** /**
* Accounts whose removal has started and not finished. * Accounts whose removal has started and not finished.
@@ -263,13 +248,6 @@ class AccountsViewModel @Inject constructor(
syncTrigger.enqueue(account.displayName, expedited = true) syncTrigger.enqueue(account.displayName, expedited = true)
} }
fun rename(account: AccountEntity, label: String) {
viewModelScope.launch { repository.setLabel(account.id, label) }
}
/** What the user agreed to trust for [accountId] beyond the platform's defaults. */
fun trust(accountId: Long): Flow<ServerTrust> = serverTrust.observe(accountId)
/** The user has read what the last sync changed. */ /** The user has read what the last sync changed. */
fun dismissNotices(account: AccountEntity) { fun dismissNotices(account: AccountEntity) {
viewModelScope.launch { notices.dismiss(account.id) } viewModelScope.launch { notices.dismiss(account.id) }
@@ -17,9 +17,7 @@ import androidx.compose.material.icons.automirrored.rounded.ArrowBack
import androidx.compose.material.icons.rounded.Checklist import androidx.compose.material.icons.rounded.Checklist
import androidx.compose.material.icons.rounded.CloudOff import androidx.compose.material.icons.rounded.CloudOff
import androidx.compose.material.icons.rounded.CloudSync import androidx.compose.material.icons.rounded.CloudSync
import androidx.compose.material.icons.rounded.GppMaybe
import androidx.compose.material.icons.rounded.Lock import androidx.compose.material.icons.rounded.Lock
import androidx.compose.material.icons.rounded.LockOpen
import androidx.compose.material.icons.rounded.OpenInBrowser import androidx.compose.material.icons.rounded.OpenInBrowser
import androidx.compose.material.icons.rounded.Warning import androidx.compose.material.icons.rounded.Warning
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
@@ -181,8 +179,6 @@ internal fun AddAccountScreen(
is AddAccountStep.Working -> WorkingStep(step) is AddAccountStep.Working -> WorkingStep(step)
is AddAccountStep.EnterCredentials -> CredentialsStep(step, viewModel) is AddAccountStep.EnterCredentials -> CredentialsStep(step, viewModel)
is AddAccountStep.ConfirmBrowser -> ConfirmBrowserStep(step) is AddAccountStep.ConfirmBrowser -> ConfirmBrowserStep(step)
is AddAccountStep.ConfirmCleartext -> ConfirmCleartextStep(step)
is AddAccountStep.TrustCertificate -> TrustCertificateStep(step)
is AddAccountStep.WaitingForBrowser -> BrowserStep(step) is AddAccountStep.WaitingForBrowser -> BrowserStep(step)
is AddAccountStep.ChooseLists -> ListsStep(step, viewModel) is AddAccountStep.ChooseLists -> ListsStep(step, viewModel)
is AddAccountStep.Summary -> SummaryStep(step) is AddAccountStep.Summary -> SummaryStep(step)
@@ -232,11 +228,7 @@ private fun AddAccountStep.position(hasSetup: Boolean): Int? {
return when (this) { return when (this) {
is AddAccountStep.ChooseProvider -> 1 is AddAccountStep.ChooseProvider -> 1
is AddAccountStep.PrepareAccess -> 2 is AddAccountStep.PrepareAccess -> 2
// Both are about reaching the server, so they keep the address's slot. is AddAccountStep.EnterAddress -> 2 + errand
is AddAccountStep.EnterAddress,
is AddAccountStep.ConfirmCleartext,
is AddAccountStep.TrustCertificate,
-> 2 + errand
// One slot, because only one of the three ever happens: a server either // 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 // hands the sign-in to a browser — asking first, where the URL needs
// confirming — or asks for a password. // confirming — or asks for a password.
@@ -280,8 +272,6 @@ private fun StepHero(state: AddAccountUiState) {
// The warning is the screen, so it is the mark too — the browser icon // 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. // 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.ConfirmBrowser -> Icons.Rounded.Warning
state.step is AddAccountStep.ConfirmCleartext -> Icons.Rounded.LockOpen
state.step is AddAccountStep.TrustCertificate -> Icons.Rounded.GppMaybe
state.step is AddAccountStep.WaitingForBrowser -> Icons.Rounded.OpenInBrowser state.step is AddAccountStep.WaitingForBrowser -> Icons.Rounded.OpenInBrowser
state.step is AddAccountStep.ChooseLists -> Icons.Rounded.Checklist state.step is AddAccountStep.ChooseLists -> Icons.Rounded.Checklist
else -> Icons.Rounded.CloudSync else -> Icons.Rounded.CloudSync
@@ -321,10 +311,6 @@ private fun StepTitle(step: AddAccountStep) {
R.string.add_account_credentials_title to R.string.add_account_credentials_body R.string.add_account_credentials_title to R.string.add_account_credentials_body
is AddAccountStep.ConfirmBrowser -> is AddAccountStep.ConfirmBrowser ->
R.string.add_account_browser_confirm_title to R.string.add_account_browser_confirm_body R.string.add_account_browser_confirm_title to R.string.add_account_browser_confirm_body
is AddAccountStep.ConfirmCleartext ->
R.string.add_account_cleartext_title to R.string.add_account_cleartext_body
is AddAccountStep.TrustCertificate ->
R.string.add_account_certificate_title to R.string.add_account_certificate_body
is AddAccountStep.ChooseLists -> is AddAccountStep.ChooseLists ->
R.string.add_account_lists_title to R.string.add_account_lists_body R.string.add_account_lists_title to R.string.add_account_lists_body
is AddAccountStep.Summary -> is AddAccountStep.Summary ->
@@ -426,34 +412,6 @@ private fun ColumnScope.StepActions(state: AddAccountUiState, viewModel: AddAcco
) { Text(stringResource(R.string.add_account_browser_use_password)) } ) { Text(stringResource(R.string.add_account_browser_use_password)) }
} }
// Plain http stays a deliberate choice: going forward is primary, as it
// is for the browser warning, and changing the address is right below.
is AddAccountStep.ConfirmCleartext -> {
PrimaryAction(
label = stringResource(R.string.add_account_cleartext_connect),
enabled = true,
onClick = viewModel::onCleartextConfirmed,
)
TextButton(
onClick = { viewModel.onBackWithin() },
modifier = Modifier.fillMaxWidth(),
) { Text(stringResource(R.string.add_account_change_address)) }
}
// The safe answer stays visible, as it does for plain http: back is
// not something every user knows to look for on a security prompt.
is AddAccountStep.TrustCertificate -> {
PrimaryAction(
label = stringResource(R.string.add_account_certificate_trust),
enabled = true,
onClick = viewModel::onCertificateTrusted,
)
TextButton(
onClick = { viewModel.onBackWithin() },
modifier = Modifier.fillMaxWidth(),
) { Text(stringResource(R.string.add_account_certificate_decline)) }
}
is AddAccountStep.WaitingForBrowser -> { is AddAccountStep.WaitingForBrowser -> {
// A failed flow is retried by default. With no browser a retry // A failed flow is retried by default. With no browser a retry
// cannot help; while the server throttles or is in maintenance an // cannot help; while the server throttles or is in maintenance an
@@ -21,7 +21,6 @@ internal val CalDavDiscovery.Outcome.Cause.message: Int
CalDavDiscovery.Outcome.Cause.NOT_A_DAV_SERVER -> R.string.add_account_error_not_dav 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.NO_CALENDAR_SUPPORT -> R.string.add_account_error_no_calendar
CalDavDiscovery.Outcome.Cause.UNREACHABLE -> R.string.add_account_error_unreachable CalDavDiscovery.Outcome.Cause.UNREACHABLE -> R.string.add_account_error_unreachable
CalDavDiscovery.Outcome.Cause.TLS -> R.string.add_account_error_tls
CalDavDiscovery.Outcome.Cause.INSECURE -> R.string.add_account_error_insecure 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.NO_CALENDARS -> R.string.add_account_error_no_calendars
CalDavDiscovery.Outcome.Cause.SERVER_ERROR -> R.string.add_account_error_server CalDavDiscovery.Outcome.Cause.SERVER_ERROR -> R.string.add_account_error_server
@@ -8,18 +8,13 @@ import de.jeanlucmakiola.agendula.data.sync.AccountCreator
import de.jeanlucmakiola.agendula.data.sync.AccountRepository import de.jeanlucmakiola.agendula.data.sync.AccountRepository
import de.jeanlucmakiola.agendula.data.sync.CalDavGateway import de.jeanlucmakiola.agendula.data.sync.CalDavGateway
import de.jeanlucmakiola.agendula.data.sync.LoginFlowRecord import de.jeanlucmakiola.agendula.data.sync.LoginFlowRecord
import de.jeanlucmakiola.agendula.data.sync.ServerTrustKeys
import de.jeanlucmakiola.caldav.CalDavDiscovery import de.jeanlucmakiola.caldav.CalDavDiscovery
import de.jeanlucmakiola.caldav.CalDavProvider import de.jeanlucmakiola.caldav.CalDavProvider
import de.jeanlucmakiola.caldav.NextcloudLoginFlow import de.jeanlucmakiola.caldav.NextcloudLoginFlow
import de.jeanlucmakiola.caldav.ServerCertificate
import de.jeanlucmakiola.caldav.ServerQuirk import de.jeanlucmakiola.caldav.ServerQuirk
import de.jeanlucmakiola.caldav.ServerTrust
import de.jeanlucmakiola.caldav.ServiceDiscovery import de.jeanlucmakiola.caldav.ServiceDiscovery
import de.jeanlucmakiola.caldav.TaskCollection import de.jeanlucmakiola.caldav.TaskCollection
import de.jeanlucmakiola.agendula.data.di.ApplicationScope import de.jeanlucmakiola.agendula.data.di.ApplicationScope
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@@ -121,12 +116,6 @@ sealed interface AddAccountStep {
val insecure: Boolean = false, val insecure: Boolean = false,
) : AddAccountStep ) : AddAccountStep
/** The typed address is plain http; the user has to accept that before anything is sent. */
data class ConfirmCleartext(val address: String) : AddAccountStep
/** The server's certificate is not trusted by the platform; the user may pin it. */
data class TrustCertificate(val certificate: ServerCertificate) : AddAccountStep
/** /**
* The browser has the flow. We poll until the user approves, and offer an * The browser has the flow. We poll until the user approves, and offer an
* explicit way out — Custom Tabs return **no result** when dismissed, and * explicit way out — Custom Tabs return **no result** when dismissed, and
@@ -338,36 +327,6 @@ class AddAccountViewModel @Inject constructor(
/** The flow the browser was last sent to, for a retry while it is still open. */ /** The flow the browser was last sent to, for a retry while it is still open. */
private var browserFlow: NextcloudLoginFlow.Flow? = null private var browserFlow: NextcloudLoginFlow.Flow? = null
/** What the user agreed to trust in this flow, handed to every gateway call. */
private var trust: ServerTrust
get() = TRUST_KEYS.read(handle)
set(value) = TRUST_KEYS.write(handle, value)
/**
* The address [trust] was given for. Pins and plain http both belong to it,
* so submitting any other address starts from nothing.
*/
private var trustAddress: String?
get() = handle[KEY_TRUST_ADDRESS]
set(value) { handle[KEY_TRUST_ADDRESS] = value }
/**
* The pins in [trust] came from the account being signed in to, not from
* this flow. The first certificate trusted now replaces them, so a renewed
* certificate retires the one it renews instead of joining it.
*/
private var inheritedPins: Boolean
get() = handle[KEY_INHERITED_PINS] ?: false
set(value) { handle[KEY_INHERITED_PINS] = value }
/** Loading a re-authenticated account's stored trust, which discovery waits for. */
private var trustLoad: Job? = null
/** The open [AddAccountStep.TrustCertificate] question, and where declining it goes. */
private class TrustPrompt(val answer: CompletableDeferred<Unit>, val back: AddAccountStep)
private var trustPrompt: TrustPrompt? = null
/** Which hosts asked for credentials, for the cross-domain diagnostic. */ /** Which hosts asked for credentials, for the cross-domain diagnostic. */
private var hostsNeedingAuth: List<String> private var hostsNeedingAuth: List<String>
get() = handle.get<ArrayList<String>>(KEY_HOSTS_NEEDING_AUTH).orEmpty() get() = handle.get<ArrayList<String>>(KEY_HOSTS_NEEDING_AUTH).orEmpty()
@@ -465,15 +424,6 @@ class AddAccountViewModel @Inject constructor(
reauthenticating = true, reauthenticating = true,
) )
} }
// Starts from what the account already trusts, so a renewed certificate
// is the only thing asked about again.
val address = addressInput
trustLoad = viewModelScope.launch {
val stored = runCatching { repository.trustFor(accountId) }.getOrDefault(ServerTrust.DEFAULT)
trust = stored
trustAddress = address
inheritedPins = stored.pinnedCertificates.isNotEmpty()
}
} }
/** The errand is read; on to the address. */ /** The errand is read; on to the address. */
@@ -548,25 +498,10 @@ class AddAccountViewModel @Inject constructor(
} }
serverRoot = ServiceDiscovery.serverRootFor(input) serverRoot = ServiceDiscovery.serverRootFor(input)
discoverAddress()
}
/** Discovery for the submitted address, without credentials. */
private fun discoverAddress() {
working(AddAccountMessage.Progress.Discovering) working(AddAccountMessage.Progress.Discovering)
viewModelScope.launch { viewModelScope.launch {
// ⚠️ After the stored trust has landed, not before: reset first and when (val outcome = gateway.discover(typedInput)) {
// the load would put the old address's trust back on top.
trustLoad?.join()
if (typedInput != trustAddress) {
trust = ServerTrust.DEFAULT
inheritedPins = false
trustAddress = typedInput
}
val outcome = discoverTrusting(addressStep(typedInput), AddAccountMessage.Progress.Discovering) {
gateway.discover(typedInput, trust = trust)
} ?: return@launch
when (outcome) {
is CalDavDiscovery.Outcome.Found -> onDiscovered(outcome) is CalDavDiscovery.Outcome.Found -> onDiscovered(outcome)
is CalDavDiscovery.Outcome.NeedsAuthentication -> { is CalDavDiscovery.Outcome.NeedsAuthentication -> {
@@ -582,71 +517,11 @@ class AddAccountViewModel @Inject constructor(
is CalDavDiscovery.Outcome.NotCalDav -> backToAddressOrServer(outcome.cause) is CalDavDiscovery.Outcome.NotCalDav -> backToAddressOrServer(outcome.cause)
is CalDavDiscovery.Outcome.Failed -> if (needsCleartextConsent(outcome.cause)) { is CalDavDiscovery.Outcome.Failed -> backToAddressOrServer(outcome.cause)
updateState { it.copy(step = AddAccountStep.ConfirmCleartext(typedInput)) }
} else {
backToAddressOrServer(outcome.cause)
}
// Asked about inside discoverTrusting, which only returns it declined.
is CalDavDiscovery.Outcome.UntrustedCertificate -> Unit
} }
} }
} }
/** A typed http:// address the user has not yet accepted as unencrypted. */
private fun needsCleartextConsent(cause: CalDavDiscovery.Outcome.Cause): Boolean =
cause == CalDavDiscovery.Outcome.Cause.INSECURE && !trust.allowCleartext &&
typedInput.toHttpUrlOrNull()?.isHttps == false
/** The warning is read and plain http is wanted anyway. */
fun onCleartextConfirmed() {
if (current.step !is AddAccountStep.ConfirmCleartext) return
trust = trust.copy(allowCleartext = true)
trustAddress = typedInput
discoverAddress()
}
/** The fingerprint is checked; pin it and re-run what it stopped. */
fun onCertificateTrusted() {
val step = current.step as? AddAccountStep.TrustCertificate ?: return
val prompt = trustPrompt ?: return
trustPrompt = null
val fingerprint = step.certificate.fingerprint
trust = if (inheritedPins) {
trust.copy(pinnedCertificates = setOf(fingerprint.lowercase()))
} else {
trust.withCertificate(fingerprint)
}
inheritedPins = false
prompt.answer.complete(Unit)
}
/**
* Runs [discover], and on a certificate the user could trust asks about it
* and runs it again once they have. Null when they declined; the step is
* then already [back].
*/
private suspend fun discoverTrusting(
back: AddAccountStep,
progress: AddAccountMessage.Progress,
discover: suspend () -> CalDavDiscovery.Outcome,
): CalDavDiscovery.Outcome? {
while (true) {
val outcome = discover()
if (outcome !is CalDavDiscovery.Outcome.UntrustedCertificate) return outcome
val prompt = TrustPrompt(CompletableDeferred(), back)
trustPrompt = prompt
updateState { it.copy(step = AddAccountStep.TrustCertificate(outcome.certificate), openInBrowser = null) }
try {
prompt.answer.await()
} catch (_: CancellationException) {
return null
}
working(progress)
}
}
/** /**
* An email address whose domain has no CalDAV server goes straight to the * An email address whose domain has no CalDAV server goes straight to the
* server field rather than an error with nowhere to go; the email stays as * server field rather than an error with nowhere to go; the email stays as
@@ -687,10 +562,7 @@ class AddAccountViewModel @Inject constructor(
if (step.username.isBlank() || step.password.isEmpty()) return if (step.username.isBlank() || step.password.isEmpty()) return
username = step.username.trim() username = step.username.trim()
appPassword = step.password appPassword = step.password
discoverWithCredentials()
}
private fun discoverWithCredentials() {
working(AddAccountMessage.Progress.SigningIn) working(AddAccountMessage.Progress.SigningIn)
viewModelScope.launch { viewModelScope.launch {
// ⚠️ An error, not a silent unauthenticated retry. serverRootFor // ⚠️ An error, not a silent unauthenticated retry. serverRootFor
@@ -701,11 +573,7 @@ class AddAccountViewModel @Inject constructor(
val root = serverRoot val root = serverRoot
?: return@launch backToAddress(CalDavDiscovery.Outcome.Cause.NOT_AN_ADDRESS) ?: return@launch backToAddress(CalDavDiscovery.Outcome.Cause.NOT_AN_ADDRESS)
val credentials = CalDavGateway.Credentials(username, appPassword, root) val credentials = CalDavGateway.Credentials(username, appPassword, root)
val outcome = discoverTrusting( when (val outcome = gateway.discover(typedInput, credentials)) {
AddAccountStep.EnterCredentials(username = username),
AddAccountMessage.Progress.SigningIn,
) { gateway.discover(typedInput, credentials, trust) } ?: return@launch
when (outcome) {
is CalDavDiscovery.Outcome.Found -> onDiscovered(outcome) is CalDavDiscovery.Outcome.Found -> onDiscovered(outcome)
// A second rejection is the point at which naming the provider's // A second rejection is the point at which naming the provider's
@@ -727,7 +595,6 @@ class AddAccountViewModel @Inject constructor(
is CalDavDiscovery.Outcome.NotCalDav -> backToAddressOrServer(outcome.cause) is CalDavDiscovery.Outcome.NotCalDav -> backToAddressOrServer(outcome.cause)
is CalDavDiscovery.Outcome.Failed -> backToAddressOrServer(outcome.cause) is CalDavDiscovery.Outcome.Failed -> backToAddressOrServer(outcome.cause)
is CalDavDiscovery.Outcome.UntrustedCertificate -> Unit
} }
} }
} }
@@ -746,7 +613,7 @@ class AddAccountViewModel @Inject constructor(
/** Step 3b → the browser flow, if this looks like a Nextcloud. */ /** Step 3b → the browser flow, if this looks like a Nextcloud. */
private suspend fun offerSignIn() { private suspend fun offerSignIn() {
val root = serverRoot ?: return backToAddress(CalDavDiscovery.Outcome.Cause.NOT_AN_ADDRESS) val root = serverRoot ?: return backToAddress(CalDavDiscovery.Outcome.Cause.NOT_AN_ADDRESS)
val flow = gateway.startLoginFlow(root, trust) val flow = gateway.startLoginFlow(root)
if (flow == null) { if (flow == null) {
// Not a Nextcloud, or its login flow is unavailable. Ask for a // Not a Nextcloud, or its login flow is unavailable. Ask for a
@@ -807,7 +674,7 @@ class AddAccountViewModel @Inject constructor(
*/ */
private suspend fun launchBrowser(flow: NextcloudLoginFlow.Flow) { private suspend fun launchBrowser(flow: NextcloudLoginFlow.Flow) {
browserFlow = flow browserFlow = flow
runCatching { pendingFlow.remember(flow, trust) } runCatching { pendingFlow.remember(flow) }
updateState { updateState {
it.copy( it.copy(
step = AddAccountStep.WaitingForBrowser(hostMismatch = flow.hostMismatch), step = AddAccountStep.WaitingForBrowser(hostMismatch = flow.hostMismatch),
@@ -836,7 +703,7 @@ class AddAccountViewModel @Inject constructor(
// later poll can only report expiry. Bounded by the gateway's // later poll can only report expiry. Bounded by the gateway's
// own per-call budget, and the delay above stays cancellable, so // own per-call budget, and the delay above stays cancellable, so
// the loop still stops promptly. // the loop still stops promptly.
val result = withContext(NonCancellable) { gateway.pollLoginFlow(flow, trust) } val result = withContext(NonCancellable) { gateway.pollLoginFlow(flow) }
when (result) { when (result) {
is NextcloudLoginFlow.PollResult.Approved -> { is NextcloudLoginFlow.PollResult.Approved -> {
// ⚠️ Uncancellable. The 200 that carried this password is // ⚠️ Uncancellable. The 200 that carried this password is
@@ -875,7 +742,47 @@ class AddAccountViewModel @Inject constructor(
result.hostMismatch?.let { mismatch -> result.hostMismatch?.let { mismatch ->
updateState { it.copy(originMismatch = mismatch) } updateState { it.copy(originMismatch = mismatch) }
} }
discoverApproved(result.credentials.server) working(AddAccountMessage.Progress.ReadingLists)
val outcome = gateway.discover(
// The server the credentials were *issued by*, not the
// host the user typed — the two differ in exactly the
// host-mismatch case this flow already models, and
// getting it wrong burns the one-shot app password.
target = result.credentials.server.toString(),
credentials = CalDavGateway.Credentials(
username,
appPassword,
result.credentials.server,
),
)
when (outcome) {
is CalDavDiscovery.Outcome.Found -> onDiscovered(outcome)
// ⚠️ Forward what actually happened. Reporting every
// outcome as NO_CALENDARS tells someone whose server
// named an unresolvable host that their account holds
// no task lists, which is both wrong and unactionable.
is CalDavDiscovery.Outcome.Failed -> backToAddress(outcome.cause)
is CalDavDiscovery.Outcome.NotCalDav -> backToAddress(outcome.cause)
// ⚠️ These two carry no Cause, so they used to land
// on "signed in, but no task lists" — said of a
// credential the server had just rejected. A 401 here
// is either a home set outside the domain the
// credential is scoped to, which retrying cannot fix
// and which only mints a second password, or the
// server having a moment.
is CalDavDiscovery.Outcome.NeedsAuthentication -> {
hostsNeedingAuth = outcome.hosts
val stranger = outsideCredentialScope(outcome.hosts)
if (stranger != null) {
fatal(AddAccountMessage.OutsideCredentialScope(stranger))
} else {
backToAddress(CalDavDiscovery.Outcome.Cause.SERVER_ERROR)
}
}
CalDavDiscovery.Outcome.Unauthenticated ->
backToAddress(CalDavDiscovery.Outcome.Cause.SERVER_ERROR)
}
return@launch return@launch
} }
@@ -908,52 +815,6 @@ class AddAccountViewModel @Inject constructor(
} }
} }
/** Discovery with a password the login flow just minted, at the server that issued it. */
private suspend fun discoverApproved(server: HttpUrl) {
working(AddAccountMessage.Progress.ReadingLists)
val outcome = discoverTrusting(addressStep(typedInput), AddAccountMessage.Progress.ReadingLists) {
gateway.discover(
// The server the credentials were *issued by*, not the
// host the user typed — the two differ in exactly the
// host-mismatch case this flow already models, and
// getting it wrong burns the one-shot app password.
target = server.toString(),
credentials = CalDavGateway.Credentials(username, appPassword, server),
trust = trust,
)
} ?: return
when (outcome) {
is CalDavDiscovery.Outcome.Found -> onDiscovered(outcome)
// ⚠️ Forward what actually happened. Reporting every
// outcome as NO_CALENDARS tells someone whose server
// named an unresolvable host that their account holds
// no task lists, which is both wrong and unactionable.
is CalDavDiscovery.Outcome.Failed -> backToAddress(outcome.cause)
is CalDavDiscovery.Outcome.NotCalDav -> backToAddress(outcome.cause)
// ⚠️ These two carry no Cause, so they used to land
// on "signed in, but no task lists" — said of a
// credential the server had just rejected. A 401 here
// is either a home set outside the domain the
// credential is scoped to, which retrying cannot fix
// and which only mints a second password, or the
// server having a moment.
is CalDavDiscovery.Outcome.NeedsAuthentication -> {
hostsNeedingAuth = outcome.hosts
val stranger = outsideCredentialScope(outcome.hosts)
if (stranger != null) {
fatal(AddAccountMessage.OutsideCredentialScope(stranger))
} else {
backToAddress(CalDavDiscovery.Outcome.Cause.SERVER_ERROR)
}
}
CalDavDiscovery.Outcome.Unauthenticated ->
backToAddress(CalDavDiscovery.Outcome.Cause.SERVER_ERROR)
is CalDavDiscovery.Outcome.UntrustedCertificate -> Unit
}
}
/** /**
* Another go after the browser sign-in failed. * Another go after the browser sign-in failed.
* *
@@ -1037,7 +898,6 @@ class AddAccountViewModel @Inject constructor(
found = discovered, found = discovered,
selected = chosen, selected = chosen,
reauthenticating = reauthAccountId, reauthenticating = reauthAccountId,
trust = trust,
) )
}.getOrElse { }.getOrElse {
// A constraint violation or an IO failure in DataStore must not // A constraint violation or an IO failure in DataStore must not
@@ -1144,19 +1004,6 @@ class AddAccountViewModel @Inject constructor(
true true
} }
is AddAccountStep.ConfirmCleartext -> {
updateState { it.copy(step = addressStep(typedInput), fatal = null) }
true
}
is AddAccountStep.TrustCertificate -> {
val prompt = trustPrompt
trustPrompt = null
prompt?.answer?.cancel()
updateState { it.copy(step = prompt?.back ?: addressStep(typedInput), fatal = null) }
true
}
else -> false else -> false
} }
@@ -1198,13 +1045,6 @@ class AddAccountViewModel @Inject constructor(
reauthAccountId = null reauthAccountId = null
suggestedUsername = "" suggestedUsername = ""
fallbackEmail = null fallbackEmail = null
trustLoad?.cancel()
trustLoad = null
trust = ServerTrust.DEFAULT
trustAddress = null
inheritedPins = false
trustPrompt?.answer?.cancel()
trustPrompt = null
current = AddAccountUiState() current = AddAccountUiState()
} }
@@ -1228,10 +1068,9 @@ class AddAccountViewModel @Inject constructor(
private fun discardMintedPassword() { private fun discardMintedPassword() {
val credentials = minted ?: return val credentials = minted ?: return
minted = null minted = null
val trust = trust
// The scope has no exception handler, so anything escaping here takes the // The scope has no exception handler, so anything escaping here takes the
// app down — for a courtesy call whose failures are deliberately silent. // app down — for a courtesy call whose failures are deliberately silent.
appScope.launch { runCatching { gateway.revokeIssuedAppPassword(credentials, trust) } } appScope.launch { runCatching { gateway.revokeIssuedAppPassword(credentials) } }
} }
// -------------------------------------------------------- saved state // -------------------------------------------------------- saved state
@@ -1256,8 +1095,6 @@ class AddAccountViewModel @Inject constructor(
// what deals with that on the next open. Recording them as // what deals with that on the next open. Recording them as
// themselves would only mean deciding the same thing twice. // themselves would only mean deciding the same thing twice.
is AddAccountStep.ConfirmBrowser, is AddAccountStep.WaitingForBrowser -> STEP_ADDRESS is AddAccountStep.ConfirmBrowser, is AddAccountStep.WaitingForBrowser -> STEP_ADDRESS
// Both re-ask on the next submit, which is where a restore puts them.
is AddAccountStep.ConfirmCleartext, is AddAccountStep.TrustCertificate -> STEP_ADDRESS
is AddAccountStep.Working -> STEP_WORKING is AddAccountStep.Working -> STEP_WORKING
is AddAccountStep.ChooseLists -> STEP_LISTS is AddAccountStep.ChooseLists -> STEP_LISTS
is AddAccountStep.Summary -> STEP_SUMMARY is AddAccountStep.Summary -> STEP_SUMMARY
@@ -1363,10 +1200,7 @@ class AddAccountViewModel @Inject constructor(
) )
viewModelScope.launch { viewModelScope.launch {
val credentials = CalDavGateway.Credentials(username, appPassword, root) val credentials = CalDavGateway.Credentials(username, appPassword, root)
val outcome = discoverTrusting(addressStep(addressInput), AddAccountMessage.Progress.ReadingLists) { when (val outcome = gateway.discover(target, credentials)) {
gateway.discover(target, credentials, trust)
} ?: return@launch
when (outcome) {
is CalDavDiscovery.Outcome.Found -> onDiscovered(outcome, selection) is CalDavDiscovery.Outcome.Found -> onDiscovered(outcome, selection)
is CalDavDiscovery.Outcome.NotCalDav -> backToAddressOrServer(outcome.cause) is CalDavDiscovery.Outcome.NotCalDav -> backToAddressOrServer(outcome.cause)
is CalDavDiscovery.Outcome.Failed -> backToAddressOrServer(outcome.cause) is CalDavDiscovery.Outcome.Failed -> backToAddressOrServer(outcome.cause)
@@ -1377,7 +1211,6 @@ class AddAccountViewModel @Inject constructor(
credentialsRejected() credentialsRejected()
} }
CalDavDiscovery.Outcome.Unauthenticated -> credentialsRejected() CalDavDiscovery.Outcome.Unauthenticated -> credentialsRejected()
is CalDavDiscovery.Outcome.UntrustedCertificate -> Unit
} }
} }
} }
@@ -1553,14 +1386,10 @@ class AddAccountViewModel @Inject constructor(
} }
} }
// The port is part of the name: two servers on one host are two accounts (#105).
private fun accountName(): String = private fun accountName(): String =
username.takeIf { it.isNotBlank() }?.let { "$it@${serverRoot?.authority().orEmpty()}" } username.takeIf { it.isNotBlank() }?.let { "$it@${serverRoot?.host.orEmpty()}" }
?: typedInput ?: typedInput
private fun HttpUrl.authority(): String =
if (port == HttpUrl.defaultPort(scheme)) host else "$host:$port"
internal companion object { internal companion object {
const val POLL_INTERVAL_MILLIS = 2_000L const val POLL_INTERVAL_MILLIS = 2_000L
@@ -1605,21 +1434,12 @@ class AddAccountViewModel @Inject constructor(
const val KEY_REAUTH_ACCOUNT = "add_account.reauth_account" const val KEY_REAUTH_ACCOUNT = "add_account.reauth_account"
const val KEY_SUGGESTED_USERNAME = "add_account.suggested_username" const val KEY_SUGGESTED_USERNAME = "add_account.suggested_username"
const val KEY_FALLBACK_EMAIL = "add_account.fallback_email" const val KEY_FALLBACK_EMAIL = "add_account.fallback_email"
const val KEY_TRUSTED_CERTIFICATES = "add_account.trusted_certificates"
const val KEY_CLEARTEXT = "add_account.cleartext"
const val KEY_TRUST_ADDRESS = "add_account.trust_address"
const val KEY_INHERITED_PINS = "add_account.inherited_pins"
val TRUST_KEYS = ServerTrustKeys(KEY_TRUSTED_CERTIFICATES, KEY_CLEARTEXT)
/** What sends an email to the server field: no CalDAV on its domain, or nothing answering for it. */ /** What sends an email to the server field: no CalDAV on its domain, or nothing answering for it. */
val NO_SERVER_FOUND = setOf( val NO_SERVER_FOUND = setOf(
CalDavDiscovery.Outcome.Cause.NOT_A_DAV_SERVER, CalDavDiscovery.Outcome.Cause.NOT_A_DAV_SERVER,
CalDavDiscovery.Outcome.Cause.NO_CALENDAR_SUPPORT, CalDavDiscovery.Outcome.Cause.NO_CALENDAR_SUPPORT,
CalDavDiscovery.Outcome.Cause.UNREACHABLE, CalDavDiscovery.Outcome.Cause.UNREACHABLE,
// The domain's own host would not set up TLS: usually its website,
// not a CalDAV server, so the server field is the way on.
CalDavDiscovery.Outcome.Cause.TLS,
) )
/** /**
@@ -1,76 +0,0 @@
package de.jeanlucmakiola.agendula.ui.accounts.add
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Badge
import androidx.compose.material.icons.rounded.Event
import androidx.compose.material.icons.rounded.Fingerprint
import androidx.compose.material.icons.rounded.LockOpen
import androidx.compose.material.icons.rounded.Verified
import androidx.compose.material3.Icon
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalConfiguration
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.font.FontFamily
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.agendula.R
import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.Position
import de.jeanlucmakiola.floret.components.positionOf
import java.text.DateFormat
import java.util.Date
/** The typed address is plain http: what that exposes, before anything is sent. */
@Composable
internal fun ConfirmCleartextStep(step: AddAccountStep.ConfirmCleartext) {
GroupedRow(
title = step.address,
position = Position.Alone,
leading = { Icon(Icons.Rounded.LockOpen, contentDescription = null) },
)
Spacer(Modifier.height(16.dp))
QuirkNote(text = stringResource(R.string.add_account_cleartext_note))
}
/**
* A certificate the phone does not trust, laid out for comparing against the
* server's own. The fingerprint is the part that matters, so it comes first and
* can be selected.
*/
@Composable
internal fun TrustCertificateStep(step: AddAccountStep.TrustCertificate) {
val certificate = step.certificate
val locale = LocalConfiguration.current.locales[0]
val expires = DateFormat.getDateInstance(DateFormat.MEDIUM, locale).format(Date(certificate.notAfterMillis))
val rows = listOf(
Triple(Icons.Rounded.Fingerprint, R.string.add_account_certificate_fingerprint, certificate.displayFingerprint),
Triple(Icons.Rounded.Badge, R.string.add_account_certificate_subject, certificate.subject),
Triple(Icons.Rounded.Verified, R.string.add_account_certificate_issuer, certificate.issuer),
Triple(Icons.Rounded.Event, R.string.add_account_certificate_expires, expires),
)
SelectionContainer {
Column {
rows.forEachIndexed { index, (icon, title, value) ->
GroupedRow(
title = AnnotatedString(stringResource(title)),
summary = if (index == 0) {
buildAnnotatedString { withStyle(SpanStyle(fontFamily = FontFamily.Monospace)) { append(value) } }
} else {
AnnotatedString(value)
},
position = positionOf(index, rows.size),
leading = { Icon(icon, contentDescription = null) },
)
}
}
}
Spacer(Modifier.height(16.dp))
}
@@ -5,14 +5,11 @@
default trust anchors. That breaks exactly the self-hosting audience this app default trust anchors. That breaks exactly the self-hosting audience this app
is for, so they are added back here. is for, so they are added back here.
Cleartext is permitted here and gated in code instead, per account: every Cleartext stays off. It has been the default since API 28, and CalDavDiscovery
client CalDavHttp builds leaves ConnectionSpec.CLEARTEXT out unless the user refuses a typed http:// address for the same reason: credentials are never
opted that account into plain http behind an explicit warning, so OkHttp sent over an unencrypted connection. Any escape hatch has to be a narrow,
itself refuses an http:// URL for everyone else. This file cannot express a warned, per-account opt-in — Play's User Data policy requires modern
per-account choice, only a per-host one, and the hosts are the user's. cryptography in transit — and this file is not the place for it.
⚠️ That makes CalDavHttp the only gate. A client built any other way would
speak http silently, so HttpClientConstructionTest fails the build on one.
⚠️ KNOWN TRADE-OFF, and it is the widest form of this. base-config applies to ⚠️ KNOWN TRADE-OFF, and it is the widest form of this. base-config applies to
*all* traffic, so any CA in the user store — a corporate MDM profile, a "free *all* traffic, so any CA in the user store — a corporate MDM profile, a "free
@@ -28,7 +25,7 @@
sit alongside it. sit alongside it.
--> -->
<network-security-config> <network-security-config>
<base-config cleartextTrafficPermitted="true"> <base-config cleartextTrafficPermitted="false">
<trust-anchors> <trust-anchors>
<certificates src="system" /> <certificates src="system" />
<certificates src="user" /> <certificates src="user" />
+4 -28
View File
@@ -65,8 +65,10 @@
android:exported="true" android:exported="true"
android:launchMode="singleTop" android:launchMode="singleTop"
android:windowSoftInputMode="adjustResize"> android:windowSoftInputMode="adjustResize">
<!-- MAIN/LAUNCHER lives on the two aliases below, so the launcher <intent-filter>
name can be switched at runtime (#104). --> <action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<!-- Shared text becomes a new task's title. --> <!-- Shared text becomes a new task's title. -->
<intent-filter> <intent-filter>
<action android:name="android.intent.action.SEND" /> <action android:name="android.intent.action.SEND" />
@@ -87,32 +89,6 @@
<data android:mimeType="text/calendar" /> <data android:mimeType="text/calendar" />
</intent-filter> </intent-filter>
</activity> </activity>
<activity-alias
android:name=".DefaultNameAlias"
android:enabled="true"
android:exported="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:targetActivity=".MainActivity">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity-alias>
<activity-alias
android:name=".TasksNameAlias"
android:enabled="false"
android:exported="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name_tasks_alias"
android:roundIcon="@mipmap/ic_launcher_round"
android:targetActivity=".MainActivity">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity-alias>
<!-- Quick Settings "New task" tile. Only the system QS host can bind it. --> <!-- Quick Settings "New task" tile. Only the system QS host can bind it. -->
<service <service
@@ -12,7 +12,6 @@ import dagger.hilt.components.SingletonComponent
import de.jeanlucmakiola.agendula.data.di.ApplicationScope import de.jeanlucmakiola.agendula.data.di.ApplicationScope
import de.jeanlucmakiola.agendula.data.di.ChannelRefresher import de.jeanlucmakiola.agendula.data.di.ChannelRefresher
import de.jeanlucmakiola.agendula.data.reminders.ReminderMaintenanceWorker import de.jeanlucmakiola.agendula.data.reminders.ReminderMaintenanceWorker
import de.jeanlucmakiola.agendula.data.reminders.ReminderRefresher
import de.jeanlucmakiola.agendula.data.reminders.ReminderScheduler import de.jeanlucmakiola.agendula.data.reminders.ReminderScheduler
import de.jeanlucmakiola.agendula.data.reminders.TaskNotifier import de.jeanlucmakiola.agendula.data.reminders.TaskNotifier
import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver
@@ -66,12 +65,15 @@ class AgendulaApp : Application(), Configuration.Provider {
// v0.3.x install's tasks, both before anything reads a store. // v0.3.x install's tasks, both before anything reads a store.
val startupGate = entryPoint.startupGate() val startupGate = entryPoint.startupGate()
val scope = entryPoint.applicationScope() val scope = entryPoint.applicationScope()
// A switch rebuilds the alarms through ReminderRefresher, which hears it // An alarm is armed off whichever store was active when it was scheduled,
// as a store change. Channels only once startup's own null -> stored // so a switch has to rebuild the set. Armed only once startup's own
// transition is past, which the launch sync below covers. // null -> stored transition is past, which the launch sync below covers.
val started = AtomicBoolean(false) val started = AtomicBoolean(false)
entryPoint.providerResolver().onModeChanged { entryPoint.providerResolver().onModeChanged {
if (started.get()) runCatching { entryPoint.taskNotifier().refreshChannel() } if (started.get()) {
scope.launch { runCatching { scheduler.sync() } }
runCatching { entryPoint.taskNotifier().refreshChannel() }
}
} }
startupGate.start() startupGate.start()
refreshNotificationChannels() refreshNotificationChannels()
@@ -86,7 +88,6 @@ class AgendulaApp : Application(), Configuration.Provider {
scheduler.sync() scheduler.sync()
} }
runCatching { entryPoint.taskWidgetUpdater().start() } runCatching { entryPoint.taskWidgetUpdater().start() }
runCatching { entryPoint.reminderRefresher().start() }
runCatching { entryPoint.providerSnapshotSource().start() } runCatching { entryPoint.providerSnapshotSource().start() }
runCatching { ReminderMaintenanceWorker.schedule(this@AgendulaApp) } runCatching { ReminderMaintenanceWorker.schedule(this@AgendulaApp) }
} }
@@ -123,7 +124,6 @@ class AgendulaApp : Application(), Configuration.Provider {
fun taskNotifier(): TaskNotifier fun taskNotifier(): TaskNotifier
fun channelRefreshers(): Set<@JvmSuppressWildcards ChannelRefresher> fun channelRefreshers(): Set<@JvmSuppressWildcards ChannelRefresher>
fun taskWidgetUpdater(): TaskWidgetUpdater fun taskWidgetUpdater(): TaskWidgetUpdater
fun reminderRefresher(): ReminderRefresher
fun providerSnapshotSource(): ProviderSnapshotSource fun providerSnapshotSource(): ProviderSnapshotSource
} }
} }
@@ -16,7 +16,6 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.core.net.toUri import androidx.core.net.toUri
import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.agendula.data.prefs.is24Hour import de.jeanlucmakiola.agendula.data.prefs.is24Hour
import de.jeanlucmakiola.agendula.ui.common.LocalFirstDayOfWeek import de.jeanlucmakiola.agendula.ui.common.LocalFirstDayOfWeek
import de.jeanlucmakiola.agendula.ui.common.LocalUse24HourFormat import de.jeanlucmakiola.agendula.ui.common.LocalUse24HourFormat
@@ -34,12 +33,9 @@ import de.jeanlucmakiola.agendula.ui.crash.CrashReportActivity
import de.jeanlucmakiola.agendula.ui.navigation.NavRequest import de.jeanlucmakiola.agendula.ui.navigation.NavRequest
import de.jeanlucmakiola.agendula.ui.settings.SettingsViewModel import de.jeanlucmakiola.agendula.ui.settings.SettingsViewModel
import de.jeanlucmakiola.agendula.ui.theme.AgendulaTheme import de.jeanlucmakiola.agendula.ui.theme.AgendulaTheme
import de.jeanlucmakiola.agendula.ui.theme.SystemTheme
import de.jeanlucmakiola.floret.crash.CrashReportDialog import de.jeanlucmakiola.floret.crash.CrashReportDialog
import de.jeanlucmakiola.floret.crash.CrashReporter import de.jeanlucmakiola.floret.crash.CrashReporter
import de.jeanlucmakiola.floret.crash.submitCrashReport import de.jeanlucmakiola.floret.crash.submitCrashReport
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
@@ -53,8 +49,6 @@ class MainActivity : ComponentActivity() {
@Inject lateinit var launchHooks: Set<@JvmSuppressWildcards LaunchHook> @Inject lateinit var launchHooks: Set<@JvmSuppressWildcards LaunchHook>
@Inject lateinit var settingsPrefs: SettingsPrefs
// A captured crash report awaiting the user's decision, surfaced as a dialog // 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 // over the app on the next launch (the single-crash path). A startup
// crash-loop is handled out of band, before setContent — see below. // crash-loop is handled out of band, before setContent — see below.
@@ -78,13 +72,6 @@ class MainActivity : ComponentActivity() {
enableEdgeToEdge() enableEdgeToEdge()
AppShortcuts.publish(this) AppShortcuts.publish(this)
// The stored setting, never the view model's placeholder: a night mode
// that flips back and forth would recreate the activity each time.
lifecycleScope.launch {
settingsPrefs.settings.map { it.themeMode to it.dynamicColor }.distinctUntilChanged()
.collect { (mode, dynamicColor) -> SystemTheme.apply(this@MainActivity, mode, dynamicColor) }
}
// Only on a fresh launch: after a rotation or process restore the intent // Only on a fresh launch: after a rotation or process restore the intent
// is the same old one and the back stack already holds its destination. // is the same old one and the back stack already holds its destination.
val fromHistory = intent.flags and Intent.FLAG_ACTIVITY_LAUNCHED_FROM_HISTORY != 0 val fromHistory = intent.flags and Intent.FLAG_ACTIVITY_LAUNCHED_FROM_HISTORY != 0
@@ -117,7 +104,6 @@ class MainActivity : ComponentActivity() {
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
navRequest = navRequest, navRequest = navRequest,
onNavRequestConsumed = { navRequest = null }, onNavRequestConsumed = { navRequest = null },
onReturnHome = ::finish,
) )
pendingCrashReport?.let { report -> pendingCrashReport?.let { report ->
CrashReportDialog( CrashReportDialog(
@@ -166,9 +152,7 @@ class MainActivity : ComponentActivity() {
private const val EXTRA_FILTER_ID = "de.jeanlucmakiola.agendula.extra.FILTER_ID" 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_SMART_LIST = "de.jeanlucmakiola.agendula.extra.SMART_LIST"
private const val EXTRA_LIST_ID = "de.jeanlucmakiola.agendula.extra.LIST_ID" private const val EXTRA_LIST_ID = "de.jeanlucmakiola.agendula.extra.LIST_ID"
private const val EXTRA_RETURN_HOME = "de.jeanlucmakiola.agendula.extra.RETURN_HOME"
private const val EXTRA_PRESET_LIST_ID = "de.jeanlucmakiola.agendula.extra.PRESET_LIST_ID" private const val EXTRA_PRESET_LIST_ID = "de.jeanlucmakiola.agendula.extra.PRESET_LIST_ID"
private const val EXTRA_DUE_TODAY = "de.jeanlucmakiola.agendula.extra.DUE_TODAY"
private const val SHARED_TITLE_LIMIT = 500 private const val SHARED_TITLE_LIMIT = 500
private const val NO_OCCURRENCE = -1L private const val NO_OCCURRENCE = -1L
@@ -176,16 +160,10 @@ class MainActivity : ComponentActivity() {
* Opens a task's detail (reminder taps). [occurrenceStart] (epoch millis) * Opens a task's detail (reminder taps). [occurrenceStart] (epoch millis)
* picks the occurrence of a recurring task. * picks the occurrence of a recurring task.
*/ */
fun taskIntent( fun taskIntent(context: Context, taskId: Long, occurrenceStart: Long? = null): Intent =
context: Context,
taskId: Long,
occurrenceStart: Long? = null,
returnHome: Boolean = false,
): Intent =
Intent(context, MainActivity::class.java).apply { Intent(context, MainActivity::class.java).apply {
putExtra(EXTRA_TASK_ID, taskId) putExtra(EXTRA_TASK_ID, taskId)
putExtra(EXTRA_OCCURRENCE_START, occurrenceStart ?: NO_OCCURRENCE) putExtra(EXTRA_OCCURRENCE_START, occurrenceStart ?: NO_OCCURRENCE)
putExtra(EXTRA_RETURN_HOME, returnHome)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
} }
@@ -197,17 +175,13 @@ class MainActivity : ComponentActivity() {
/** /**
* The launcher shortcut's, Quick Settings tile's and a widget's "New * The launcher shortcut's, Quick Settings tile's and a widget's "New
* task". [listId] preset the list when it names exactly one, [dueToday] * task". [listId] preset the list when it names exactly one; the data
* the due date for a Today widget; the data URI keeps each widget's * URI keeps each widget's PendingIntent apart from the others.
* PendingIntent apart from the others.
*/ */
fun newTaskIntent(context: Context, listId: Long? = null, dueToday: Boolean = false): Intent { fun newTaskIntent(context: Context, listId: Long? = null): Intent =
val path = listOfNotNull(listId?.toString(), "today".takeIf { dueToday }).joinToString("/") Intent(ACTION_NEW_TASK, listId?.let { "agendula://newtask/$it".toUri() }, context, MainActivity::class.java)
return Intent(ACTION_NEW_TASK, path.ifEmpty { null }?.let { "agendula://newtask/$it".toUri() }, context, MainActivity::class.java)
.putExtra(EXTRA_PRESET_LIST_ID, listId ?: -1L) .putExtra(EXTRA_PRESET_LIST_ID, listId ?: -1L)
.putExtra(EXTRA_DUE_TODAY, dueToday)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
fun todayIntent(context: Context): Intent = fun todayIntent(context: Context): Intent =
Intent(ACTION_TODAY, null, context, MainActivity::class.java) Intent(ACTION_TODAY, null, context, MainActivity::class.java)
@@ -241,8 +215,6 @@ class MainActivity : ComponentActivity() {
when (intent.action) { 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 }, listId = intent.getLongExtra(EXTRA_PRESET_LIST_ID, -1L).takeIf { it > 0L },
dueToday = intent.getBooleanExtra(EXTRA_DUE_TODAY, false),
returnHome = true,
) )
ACTION_TODAY -> return NavRequest.OpenSmart(SmartList.TODAY) ACTION_TODAY -> return NavRequest.OpenSmart(SmartList.TODAY)
ACTION_OPEN_SMART -> intent.getStringExtra(EXTRA_SMART_LIST) ACTION_OPEN_SMART -> intent.getStringExtra(EXTRA_SMART_LIST)
@@ -257,12 +229,12 @@ class MainActivity : ComponentActivity() {
if (intent.action == Intent.ACTION_SEND && intent.type?.startsWith("text/plain") == true) { if (intent.action == Intent.ACTION_SEND && intent.type?.startsWith("text/plain") == true) {
val shared = intent.getStringExtra(Intent.EXTRA_SUBJECT)?.takeIf { it.isNotBlank() } val shared = intent.getStringExtra(Intent.EXTRA_SUBJECT)?.takeIf { it.isNotBlank() }
?: intent.getStringExtra(Intent.EXTRA_TEXT) ?: intent.getStringExtra(Intent.EXTRA_TEXT)
return NavRequest.NewTask(shared?.trim()?.take(SHARED_TITLE_LIMIT), returnHome = true) return NavRequest.NewTask(shared?.trim()?.take(SHARED_TITLE_LIMIT))
} }
val taskId = intent.getLongExtra(EXTRA_TASK_ID, -1L).takeIf { it > 0L } ?: return null val taskId = intent.getLongExtra(EXTRA_TASK_ID, -1L).takeIf { it > 0L } ?: return null
val occurrence = intent.getLongExtra(EXTRA_OCCURRENCE_START, NO_OCCURRENCE) val occurrence = intent.getLongExtra(EXTRA_OCCURRENCE_START, NO_OCCURRENCE)
.takeIf { it != NO_OCCURRENCE } .takeIf { it != NO_OCCURRENCE }
return NavRequest.OpenTask(taskId, occurrence, intent.getBooleanExtra(EXTRA_RETURN_HOME, false)) return NavRequest.OpenTask(taskId, occurrence)
} }
} }
} }
@@ -1,86 +0,0 @@
package de.jeanlucmakiola.agendula.data.appname
import android.content.ComponentName
import android.content.Context
import android.content.pm.PackageManager
import dagger.hilt.android.qualifiers.ApplicationContext
import de.jeanlucmakiola.agendula.ui.navigation.AppShortcuts
import javax.inject.Inject
import javax.inject.Singleton
/** The label the launcher shows for the app (#104). */
enum class LauncherName { AGENDULA, TASKS }
/** The two launcher aliases declared in the manifest. */
enum class LauncherAlias { DEFAULT, TASKS }
/** One component-enable change in an [aliasWritePlan]. */
data class AliasStateChange(val alias: LauncherAlias, val enabled: Boolean)
/**
* Reads the `TasksNameAlias` enabled state as a [LauncherName]. Only an explicit
* enable means "Tasks"; never toggled and disabled both mean the manifest default.
*/
fun launcherNameFor(tasksAliasState: Int): LauncherName =
if (tasksAliasState == PackageManager.COMPONENT_ENABLED_STATE_ENABLED) {
LauncherName.TASKS
} else {
LauncherName.AGENDULA
}
/**
* The steps to switch to [target]: enable the target alias first, then disable
* the other, so the launcher can briefly see two entries but never none.
*/
fun aliasWritePlan(target: LauncherName): List<AliasStateChange> = when (target) {
LauncherName.TASKS -> listOf(
AliasStateChange(LauncherAlias.TASKS, enabled = true),
AliasStateChange(LauncherAlias.DEFAULT, enabled = false),
)
LauncherName.AGENDULA -> listOf(
AliasStateChange(LauncherAlias.DEFAULT, enabled = true),
AliasStateChange(LauncherAlias.TASKS, enabled = false),
)
}
/**
* Switches the launcher label by enabling one of the two `<activity-alias>`
* components. The component state is the only source of truth; nothing is
* stored in prefs.
*/
@Singleton
class LauncherNameManager @Inject constructor(
@ApplicationContext private val context: Context,
) {
private val packageManager: PackageManager get() = context.packageManager
// The package is the (suffixed) applicationId, the class is namespace-qualified.
private fun component(alias: LauncherAlias): ComponentName {
val simpleName = when (alias) {
LauncherAlias.DEFAULT -> "DefaultNameAlias"
LauncherAlias.TASKS -> "TasksNameAlias"
}
return ComponentName(context.packageName, "$NAMESPACE.$simpleName")
}
fun current(): LauncherName =
launcherNameFor(packageManager.getComponentEnabledSetting(component(LauncherAlias.TASKS)))
fun set(name: LauncherName) {
for (change in aliasWritePlan(name)) {
val state = if (change.enabled) {
PackageManager.COMPONENT_ENABLED_STATE_ENABLED
} else {
PackageManager.COMPONENT_ENABLED_STATE_DISABLED
}
packageManager.setComponentEnabledSetting(component(change.alias), state, PackageManager.DONT_KILL_APP)
}
// Dynamic shortcuts hang off the launcher activity, which just changed.
AppShortcuts.publish(context)
}
private companion object {
/** `namespace` in app/build.gradle.kts, not the suffixed applicationId. */
const val NAMESPACE = "de.jeanlucmakiola.agendula"
}
}
@@ -89,7 +89,7 @@ object DataProvideModule {
// backed-up `.db` does not. The backup rules carry all three files and // backed-up `.db` does not. The backup rules carry all three files and
// the app checkpoints on ON_STOP. // the app checkpoints on ON_STOP.
.setJournalMode(RoomDatabase.JournalMode.WRITE_AHEAD_LOGGING) .setJournalMode(RoomDatabase.JournalMode.WRITE_AHEAD_LOGGING)
.addMigrations(TasksDatabase.MIGRATION_1_2, TasksDatabase.MIGRATION_2_3, TasksDatabase.MIGRATION_3_4) .addMigrations(TasksDatabase.MIGRATION_1_2, TasksDatabase.MIGRATION_2_3)
.build() .build()
/** /**
@@ -15,7 +15,6 @@ import de.jeanlucmakiola.agendula.domain.Task
import de.jeanlucmakiola.agendula.domain.TaskFilter import de.jeanlucmakiola.agendula.domain.TaskFilter
import de.jeanlucmakiola.agendula.domain.TaskFormField import de.jeanlucmakiola.agendula.domain.TaskFormField
import de.jeanlucmakiola.agendula.domain.TaskReminder import de.jeanlucmakiola.agendula.domain.TaskReminder
import de.jeanlucmakiola.agendula.domain.TaskSection
import de.jeanlucmakiola.agendula.domain.asTaskReminder import de.jeanlucmakiola.agendula.domain.asTaskReminder
import de.jeanlucmakiola.agendula.domain.asReminderLead import de.jeanlucmakiola.agendula.domain.asReminderLead
import de.jeanlucmakiola.agendula.domain.TaskSortOrder import de.jeanlucmakiola.agendula.domain.TaskSortOrder
@@ -55,7 +54,6 @@ fun TimeFormatPref.is24Hour(systemIs24Hour: Boolean): Boolean = when (this) {
} }
const val DEFAULT_SNOOZE_MINUTES = 10 const val DEFAULT_SNOOZE_MINUTES = 10
/** Done starts folded away, to keep the open tasks in focus. */ val DEFAULT_COLLAPSED_SECTIONS: Set<TaskSection> = setOf(TaskSection.COMPLETED)
/** The snooze lengths Settings offers. */ /** The snooze lengths Settings offers. */
val SNOOZE_PRESETS = listOf(5, 10, 15, 30, 60) val SNOOZE_PRESETS = listOf(5, 10, 15, 30, 60)
@@ -116,8 +114,6 @@ data class Settings(
val hiddenFromSmartLists: Set<Long> = emptySet(), val hiddenFromSmartLists: Set<Long> = emptySet(),
/** Pre-fill a new task's start with today's date. */ /** Pre-fill a new task's start with today's date. */
val defaultStartToday: Boolean = false, val defaultStartToday: Boolean = false,
/** Task-list sections folded away; Done starts folded. */
val collapsedSections: Set<TaskSection> = DEFAULT_COLLAPSED_SECTIONS,
) { ) {
/** [tasks] as [filter] shows them: a smart list drops the lists kept out of it. */ /** [tasks] as [filter] shows them: a smart list drops the lists kept out of it. */
fun visibleIn(filter: TaskFilter, tasks: List<Task>): List<Task> = fun visibleIn(filter: TaskFilter, tasks: List<Task>): List<Task> =
@@ -183,18 +179,9 @@ class SettingsPrefs @Inject constructor(
.toSet() .toSet()
}, },
defaultStartToday = p[DEFAULT_START_TODAY] ?: false, defaultStartToday = p[DEFAULT_START_TODAY] ?: false,
collapsedSections = p[COLLAPSED_SECTIONS]
?.mapNotNull { name -> runCatching { TaskSection.valueOf(name) }.getOrNull() }
?.toSet()
?: DEFAULT_COLLAPSED_SECTIONS,
) )
} }
suspend fun setSectionCollapsed(section: TaskSection, collapsed: Boolean) = dataStore.edit { p ->
val current = p[COLLAPSED_SECTIONS] ?: DEFAULT_COLLAPSED_SECTIONS.mapTo(HashSet()) { it.name }
p[COLLAPSED_SECTIONS] = if (collapsed) current + section.name else current - section.name
}
suspend fun setSortOrder(order: TaskSortOrder) = dataStore.edit { it[SORT_ORDER] = order.name } suspend fun setSortOrder(order: TaskSortOrder) = dataStore.edit { it[SORT_ORDER] = order.name }
suspend fun setSnoozeMinutes(minutes: Int) = dataStore.edit { it[SNOOZE_MINUTES] = minutes.coerceAtLeast(1) } suspend fun setSnoozeMinutes(minutes: Int) = dataStore.edit { it[SNOOZE_MINUTES] = minutes.coerceAtLeast(1) }
@@ -352,7 +339,6 @@ class SettingsPrefs @Inject constructor(
val PUSH_ENABLED = booleanPreferencesKey("push_enabled") val PUSH_ENABLED = booleanPreferencesKey("push_enabled")
val SMART_LIST_HIDDEN = stringSetPreferencesKey("smart_list_hidden") val SMART_LIST_HIDDEN = stringSetPreferencesKey("smart_list_hidden")
val DEFAULT_START_TODAY = booleanPreferencesKey("default_start_today") val DEFAULT_START_TODAY = booleanPreferencesKey("default_start_today")
val COLLAPSED_SECTIONS = stringSetPreferencesKey("collapsed_sections")
val SAVED_FILTERS = stringPreferencesKey("saved_filters") val SAVED_FILTERS = stringPreferencesKey("saved_filters")
val SAVED_FILTER_NEXT_ID = longPreferencesKey("saved_filter_next_id") val SAVED_FILTER_NEXT_ID = longPreferencesKey("saved_filter_next_id")
} }
@@ -5,7 +5,6 @@ import android.content.Context
import android.content.Intent import android.content.Intent
import androidx.core.net.toUri import androidx.core.net.toUri
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import de.jeanlucmakiola.agendula.data.prefs.Settings
import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.agendula.data.tasks.TaskQuery import de.jeanlucmakiola.agendula.data.tasks.TaskQuery
import de.jeanlucmakiola.agendula.data.tasks.TasksDataSource import de.jeanlucmakiola.agendula.data.tasks.TasksDataSource
@@ -16,7 +15,6 @@ import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
import kotlin.time.Instant
/** /**
* Fires when a task's reminder alarm goes off. Re-reads the task (it may have * Fires when a task's reminder alarm goes off. Re-reads the task (it may have
@@ -46,7 +44,8 @@ class DueReminderReceiver : BroadcastReceiver() {
} }
val settings = settingsPrefs.settings.first() val settings = settingsPrefs.settings.first()
if (!settings.remindersEnabled) return@launch if (!settings.remindersEnabled) return@launch
runCatching { notifier.postIfOpen(dataSource, taskId, occurrence, settings) } val task = runCatching { dataSource.occurrence(taskId, occurrence) }.getOrNull()
if (task != null && !task.isClosed) notifier.postDue(task, settings)
} finally { } finally {
pending.finish() pending.finish()
} }
@@ -75,51 +74,17 @@ class DueReminderReceiver : BroadcastReceiver() {
/** /**
* The occurrence of [taskId] anchored at [occurrenceStart] (epoch millis), or the * The occurrence of [taskId] anchored at [occurrenceStart] (epoch millis), or the
* task's current one when there is no anchor or it can no longer be found. * task's current one when there is no anchor or it can no longer be found.
* * [TasksDataSource.task] alone resolves a series to whichever occurrence is
* ⚠️ A ticked or edited occurrence is a row of its own with its own id, so * current, which is not necessarily the one a reminder was armed for.
* matching list rows on [taskId] misses it; [TasksDataSource.task] with the
* anchor finds it by start. The list scan is for a provider whose exception
* rows that lookup does not reach.
*/ */
internal fun TasksDataSource.occurrence(taskId: Long, occurrenceStart: Long): Task? { internal fun TasksDataSource.occurrence(taskId: Long, occurrenceStart: Long): Task? {
val anchor = occurrenceStart.takeIf { it != ScheduledReminder.NO_OCCURRENCE }?.let(Instant::fromEpochMilliseconds) val current = task(taskId) ?: return null
val found = task(taskId, anchor) ?: return null if (occurrenceStart == ScheduledReminder.NO_OCCURRENCE ||
if (anchor == null || found.occurrenceStart == anchor) return found current.occurrenceStart?.toEpochMilliseconds() == occurrenceStart
return tasks(TaskQuery(listId = found.listId, includeCompleted = true)) ) {
.firstOrNull { (it.taskId == taskId || it.seriesId == taskId) && it.occurrenceStart == anchor } return current
?: found
}
/**
* Whether a reminder posted for [occurrenceStart] is done with, given [current]
* as [occurrence] resolved it.
*
* - No task: gone, but only when [missingMeansGone] — a provider that fails a
* read hands back nothing rather than throwing.
* - [occurrenceStart] null: posted before reminders recorded their occurrence,
* so only a task that does not recur can be judged.
* - A lookup that fell back to another occurrence says nothing about the
* reminded one, so it stays.
*/
internal fun reminderIsStale(occurrenceStart: Long?, current: Task?, missingMeansGone: Boolean): Boolean {
if (current == null) return missingMeansGone
val same = when (occurrenceStart) {
null -> current.occurrenceStart == null
ScheduledReminder.NO_OCCURRENCE -> true
else -> current.occurrenceStart?.toEpochMilliseconds() == occurrenceStart
} }
return same && current.isClosed return tasks(TaskQuery(listId = current.listId, includeCompleted = true))
} .firstOrNull { it.taskId == taskId && it.occurrenceStart?.toEpochMilliseconds() == occurrenceStart }
?: current
/**
* Posts the reminder for [taskId]'s occurrence if it is still open, then checks
* once more: a completion landing between the read and the post may already
* have had its withdrawal pass, which ran before there was anything to withdraw.
*/
internal fun TaskNotifier.postIfOpen(dataSource: TasksDataSource, taskId: Long, occurrenceStart: Long, settings: Settings) {
val task = dataSource.occurrence(taskId, occurrenceStart) ?: return
if (task.isClosed) return
postDue(task, settings)
val now = runCatching { dataSource.occurrence(taskId, occurrenceStart) }.getOrElse { return }
if (now == null || now.isClosed) cancel(taskId)
} }
@@ -32,6 +32,7 @@ class ReminderActionReceiver : BroadcastReceiver() {
@Inject lateinit var notifier: TaskNotifier @Inject lateinit var notifier: TaskNotifier
@Inject lateinit var snoozeScheduler: ReminderSnoozeScheduler @Inject lateinit var snoozeScheduler: ReminderSnoozeScheduler
@Inject lateinit var reminderScheduler: ReminderScheduler
@Inject lateinit var repository: TasksRepository @Inject lateinit var repository: TasksRepository
@Inject lateinit var dataSource: TasksDataSource @Inject lateinit var dataSource: TasksDataSource
@Inject lateinit var settingsPrefs: SettingsPrefs @Inject lateinit var settingsPrefs: SettingsPrefs
@@ -53,6 +54,7 @@ class ReminderActionReceiver : BroadcastReceiver() {
val anchor = occurrence.takeIf { it != ScheduledReminder.NO_OCCURRENCE } val anchor = occurrence.takeIf { it != ScheduledReminder.NO_OCCURRENCE }
?.let(Instant::fromEpochMilliseconds) ?.let(Instant::fromEpochMilliseconds)
repository.setCompleted(taskId, anchor, completed = true) repository.setCompleted(taskId, anchor, completed = true)
reminderScheduler.sync()
} }
ACTION_SNOOZE -> runCatching { ACTION_SNOOZE -> runCatching {
@@ -65,7 +67,8 @@ class ReminderActionReceiver : BroadcastReceiver() {
val settings = settingsPrefs.settings.first() val settings = settingsPrefs.settings.first()
if (!settings.remindersEnabled) return@runCatching if (!settings.remindersEnabled) return@runCatching
startupGate.awaitReady() startupGate.awaitReady()
notifier.postIfOpen(dataSource, taskId, occurrence, settings) val task = dataSource.occurrence(taskId, occurrence)
if (task != null && !task.isClosed) notifier.postDue(task, settings)
} }
} }
} finally { } finally {
@@ -21,9 +21,7 @@ internal data class ReminderDiff(
fired: Set<ScheduledReminder> = emptySet(), fired: Set<ScheduledReminder> = emptySet(),
isArmed: (ScheduledReminder) -> Boolean, isArmed: (ScheduledReminder) -> Boolean,
): ReminderDiff { ): ReminderDiff {
// A fired entry counts even once the store dropped it, so a task val live = previous.filterTo(HashSet()) { it in fired || isArmed(it) }
// reopened within the grace does not ring again.
val live = previous.filterTo(HashSet()) { it in fired || isArmed(it) } + fired
return ReminderDiff( return ReminderDiff(
cancel = live - desired, cancel = live - desired,
arm = desired - live, arm = desired - live,
@@ -1,35 +0,0 @@
package de.jeanlucmakiola.agendula.data.reminders
import de.jeanlucmakiola.agendula.data.di.ApplicationScope
import de.jeanlucmakiola.agendula.data.tasks.StoreChangeDebouncer
import de.jeanlucmakiola.agendula.data.tasks.TasksDataSource
import kotlinx.coroutines.CoroutineScope
import javax.inject.Inject
import javax.inject.Singleton
/**
* Keeps reminders in step with the store: any change to tasks, lists or alarms —
* a tick in the list or on a widget, a sync pulling in a completion from another
* device, an edit, a store switch — runs one debounced [ReminderScheduler.sync].
* Started once from `AgendulaApp`, after the startup gate, so it observes the
* right store.
*/
@Singleton
class ReminderRefresher @Inject constructor(
dataSource: TasksDataSource,
scheduler: ReminderScheduler,
@ApplicationScope scope: CoroutineScope,
) {
private val changes = StoreChangeDebouncer(scope, DEBOUNCE_MILLIS, dataSource::registerTaskObserver) { scheduler.sync() }
fun start() {
changes.start()
}
/** Re-sync soon, folded into any pass a store change already asked for. */
fun requestRefresh() = changes.request()
private companion object {
const val DEBOUNCE_MILLIS = 1_000L
}
}
@@ -8,7 +8,6 @@ import dagger.hilt.android.qualifiers.ApplicationContext
import de.jeanlucmakiola.agendula.data.di.IoDispatcher import de.jeanlucmakiola.agendula.data.di.IoDispatcher
import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver
import de.jeanlucmakiola.agendula.data.tasks.StorageMode
import de.jeanlucmakiola.agendula.data.tasks.TaskQuery import de.jeanlucmakiola.agendula.data.tasks.TaskQuery
import de.jeanlucmakiola.agendula.data.tasks.TasksDataSource import de.jeanlucmakiola.agendula.data.tasks.TasksDataSource
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
@@ -24,9 +23,8 @@ import javax.inject.Singleton
* The self-scheduled due-reminder engine. Nothing else delivers task reminders — * The self-scheduled due-reminder engine. Nothing else delivers task reminders —
* not the platform, not a tasks provider — so Agendula reads upcoming due tasks * not the platform, not a tasks provider — so Agendula reads upcoming due tasks
* and arms one exact [AlarmManager] alarm per reminder, within a rolling window. Re-run * and arms one exact [AlarmManager] alarm per reminder, within a rolling window. Re-run
* on app start, on boot, on a store switch and on every change to the store (see * on app start, on boot, on a store switch, and on an external provider change;
* [ReminderRefresher]); it diffs against [ScheduledReminderStore] so only changed * it diffs against [ScheduledReminderStore] so only changed alarms move.
* alarms move, and takes down posted reminders whose task is done with.
*/ */
@Singleton @Singleton
class ReminderScheduler @Inject constructor( class ReminderScheduler @Inject constructor(
@@ -36,7 +34,6 @@ class ReminderScheduler @Inject constructor(
private val store: ScheduledReminderStore, private val store: ScheduledReminderStore,
private val providerResolver: ProviderResolver, private val providerResolver: ProviderResolver,
private val snoozeScheduler: ReminderSnoozeScheduler, private val snoozeScheduler: ReminderSnoozeScheduler,
private val notifier: TaskNotifier,
@IoDispatcher private val io: CoroutineDispatcher, @IoDispatcher private val io: CoroutineDispatcher,
) { ) {
private val syncLock = Mutex() private val syncLock = Mutex()
@@ -68,23 +65,19 @@ class ReminderScheduler @Inject constructor(
// Gate on whether the store is readable, not on whether a provider // Gate on whether the store is readable, not on whether a provider
// resolves: our own store deliberately resolves to no provider, so the // resolves: our own store deliberately resolves to no provider, so the
// latter clears every reminder in the default mode. // latter clears every reminder in the default mode.
val readable = providerResolver.canReadStore() if (!settings.remindersEnabled || !providerResolver.canReadStore()) {
if (!settings.remindersEnabled || !readable) {
clearAll() clearAll()
// Switched off, a reminder already up still goes when its task does.
if (readable) withdrawStale()
return return
} }
val tasks = runCatching { dataSource.tasks(TaskQuery(includeCompleted = false)) } val tasks = runCatching { dataSource.tasks(TaskQuery(includeCompleted = false)) }
.getOrElse { return } .getOrElse { return }
// Per-task leads. One query for all of them. // Per-task leads. One query for all of them.
val perTask = runCatching { dataSource.reminders() }.getOrElse { emptyMap() } val perTask = runCatching { dataSource.reminders() }.getOrElse { emptyMap() }
val now = System.currentTimeMillis()
val desired = ReminderPlanner.plan( val desired = ReminderPlanner.plan(
tasks = tasks, tasks = tasks,
perTask = perTask, perTask = perTask,
settings = settings, settings = settings,
now = now, now = System.currentTimeMillis(),
zone = ZoneId.systemDefault(), zone = ZoneId.systemDefault(),
) )
@@ -99,10 +92,7 @@ class ReminderScheduler @Inject constructor(
// ⚠️ The store survives reboot, force-stop and restore; the alarms don't. // ⚠️ The store survives reboot, force-stop and restore; the alarms don't.
// Trusting it alone left every reminder after a reboot unarmed for good. // Trusting it alone left every reminder after a reboot unarmed for good.
// ⚠️ Kept through the missed-reminder grace even once it drops out of val fired = store.fired()
// the plan: a tick undone a moment later would otherwise re-arm a
// trigger in the past, and the reminder would ring a second time.
val fired = store.fired().filterTo(HashSet()) { it in desired || it.triggerAt >= now - ReminderPlanner.MISSED_GRACE_MS }
val diff = ReminderDiff.of(store.all(), desired, fired) { !afterReboot && !rearm && isArmed(it) } val diff = ReminderDiff.of(store.all(), desired, fired) { !afterReboot && !rearm && isArmed(it) }
diff.cancel.forEach { cancel(it) } diff.cancel.forEach { cancel(it) }
// ⚠️ Only what was actually armed. A throw mid-loop used to skip the // ⚠️ Only what was actually armed. A throw mid-loop used to skip the
@@ -110,25 +100,7 @@ class ReminderScheduler @Inject constructor(
// — uncancellable, and firing for tasks that no longer exist — and the // — uncancellable, and firing for tasks that no longer exist — and the
// exception escaped into BootReceiver's goAsync(). // exception escaped into BootReceiver's goAsync().
val armed = diff.arm.filter { schedule(it) } val armed = diff.arm.filter { schedule(it) }
store.replace(diff.keep + armed, fired = fired, exact = canExact) store.replace(diff.keep + armed, fired = fired intersect desired, exact = canExact)
withdrawStale()
}
/**
* A reminder already on screen outlives its alarm: completing the task in
* the list, on a widget or on another device has to take it down too.
*/
private fun withdrawStale() {
// Only our own store tells a deleted task from a failed read.
val missingMeansGone = providerResolver.mode() == StorageMode.OWN
runCatching {
notifier.withdrawStale { taskId, occurrence ->
runCatching {
val current = dataSource.occurrence(taskId, occurrence ?: ScheduledReminder.NO_OCCURRENCE)
reminderIsStale(occurrence, current, missingMeansGone)
}.getOrDefault(false)
}
}
} }
/** Whether the system still holds this alarm's PendingIntent. */ /** Whether the system still holds this alarm's PendingIntent. */
@@ -10,7 +10,6 @@ import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.os.Build import android.os.Build
import android.os.Bundle
import android.text.format.DateFormat import android.text.format.DateFormat
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat import androidx.core.app.NotificationManagerCompat
@@ -19,7 +18,6 @@ import androidx.core.content.edit
import de.jeanlucmakiola.agendula.MainActivity import de.jeanlucmakiola.agendula.MainActivity
import de.jeanlucmakiola.agendula.R import de.jeanlucmakiola.agendula.R
import de.jeanlucmakiola.agendula.data.prefs.Settings import de.jeanlucmakiola.agendula.data.prefs.Settings
import de.jeanlucmakiola.agendula.domain.readableDescription
import de.jeanlucmakiola.agendula.data.prefs.is24Hour import de.jeanlucmakiola.agendula.data.prefs.is24Hour
import de.jeanlucmakiola.agendula.data.di.ApplicationScope import de.jeanlucmakiola.agendula.data.di.ApplicationScope
import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver
@@ -86,7 +84,6 @@ class TaskNotifier @Inject constructor(
), ),
) )
} }
val description = task.description?.readableDescription()?.trim()?.takeIf { it.isNotBlank() }
val tapIntent = PendingIntent.getActivity( val tapIntent = PendingIntent.getActivity(
context, context,
task.taskId.toInt(), task.taskId.toInt(),
@@ -96,19 +93,11 @@ class TaskNotifier @Inject constructor(
val notification = NotificationCompat.Builder(context, channelId) val notification = NotificationCompat.Builder(context, channelId)
.setSmallIcon(R.drawable.ic_notification) .setSmallIcon(R.drawable.ic_notification)
.setContentTitle(title) .setContentTitle(title)
.apply { (text ?: description)?.let(::setContentText) } .apply { if (text != null) setContentText(text) }
.apply {
expandedText(text, description)?.let { setStyle(NotificationCompat.BigTextStyle().bigText(it)) }
}
.setCategory(NotificationCompat.CATEGORY_REMINDER) .setCategory(NotificationCompat.CATEGORY_REMINDER)
.setPriority(NotificationCompat.PRIORITY_HIGH) .setPriority(NotificationCompat.PRIORITY_HIGH)
.setAutoCancel(true) .setAutoCancel(true)
.setContentIntent(tapIntent) .setContentIntent(tapIntent)
.addExtras(
Bundle().apply {
putLong(EXTRA_OCCURRENCE, task.occurrenceStart?.toEpochMilliseconds() ?: ScheduledReminder.NO_OCCURRENCE)
},
)
.addAction(0, context.getString(R.string.reminder_action_done), actionIntent(task, ReminderActionReceiver.ACTION_DONE)) .addAction(0, context.getString(R.string.reminder_action_done), actionIntent(task, ReminderActionReceiver.ACTION_DONE))
.addAction( .addAction(
0, 0,
@@ -123,24 +112,6 @@ class TaskNotifier @Inject constructor(
NotificationManagerCompat.from(context).cancel(taskId.toString(), NOTIFICATION_ID) NotificationManagerCompat.from(context).cancel(taskId.toString(), NOTIFICATION_ID)
} }
/**
* Takes down every posted reminder [isStale] says is done with — completed,
* cancelled or deleted since it went off, here or on another device. The
* occurrence is null for a reminder posted before it was recorded.
*/
fun withdrawStale(isStale: (taskId: Long, occurrenceStart: Long?) -> Boolean) {
// Only the current store's: another store's task ids name other tasks.
val prefix = "$LIST_CHANNEL_PREFIX${resolver.mode().name.lowercase()}_"
manager.activeNotifications
.filter { it.id == NOTIFICATION_ID && it.notification.channelId?.startsWith(prefix) == true }
.forEach { posted ->
val taskId = posted.tag?.toLongOrNull() ?: return@forEach
val extras = posted.notification.extras
val occurrence = if (extras.containsKey(EXTRA_OCCURRENCE)) extras.getLong(EXTRA_OCCURRENCE) else null
if (isStale(taskId, occurrence)) cancel(taskId)
}
}
private fun actionIntent(task: Task, action: String): PendingIntent = PendingIntent.getBroadcast( private fun actionIntent(task: Task, action: String): PendingIntent = PendingIntent.getBroadcast(
context, context,
task.taskId.toInt(), task.taskId.toInt(),
@@ -214,7 +185,6 @@ class TaskNotifier @Inject constructor(
private const val LEGACY_CHANNEL_ID = "task_reminders" private const val LEGACY_CHANNEL_ID = "task_reminders"
private const val LIST_CHANNEL_PREFIX = "reminders_" private const val LIST_CHANNEL_PREFIX = "reminders_"
private const val NOTIFICATION_ID = 1 private const val NOTIFICATION_ID = 1
private const val EXTRA_OCCURRENCE = "de.jeanlucmakiola.agendula.extra.OCCURRENCE_START"
private const val CHANNEL_PREFS = "reminder_channels" private const val CHANNEL_PREFS = "reminder_channels"
private const val KEY_LEGACY_IMPORTANCE = "legacy_importance" private const val KEY_LEGACY_IMPORTANCE = "legacy_importance"
@@ -270,10 +240,3 @@ private fun relativeDay(date: LocalDate, today: LocalDate, words: RelativeDays,
else -> null else -> null
} }
} }
/** The expanded body of a due reminder: the due line, then the task's description. */
internal fun expandedText(dueLine: String?, description: String?): String? = when {
description == null -> null
dueLine == null -> description
else -> "$dueLine\n\n$description"
}
@@ -1,23 +0,0 @@
package de.jeanlucmakiola.agendula.data.sync
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.flowOf
/** Pull to sync (#73): syncs every account at once. Inert in the `offline` flavor. */
interface ManualSync {
/** Whether there is anything to sync: our own store, with at least one account. */
val available: Flow<Boolean>
/** True from [syncAll] until the syncs it started have finished. */
val syncing: StateFlow<Boolean>
fun syncAll()
object None : ManualSync {
override val available: Flow<Boolean> = flowOf(false)
override val syncing: StateFlow<Boolean> = MutableStateFlow(false)
override fun syncAll() = Unit
}
}
@@ -2,5 +2,5 @@ package de.jeanlucmakiola.agendula.data.sync
/** Asks for an account to be synced now. A no-op in the `offline` flavor. */ /** Asks for an account to be synced now. A no-op in the `offline` flavor. */
fun interface SyncRequests { fun interface SyncRequests {
suspend fun syncNow(accountId: Long) fun syncNow(accountName: String)
} }
@@ -81,22 +81,16 @@ class ModeRoutingTasksDataSource(
* So the registration moves with the mode, and the switch itself counts as a * So the registration moves with the mode, and the switch itself counts as a
* change: the data underneath every live flow has just been replaced. * change: the data underneath every live flow has just been replaced.
*/ */
override fun registerObserver(onChange: () -> Unit): AutoCloseable = override fun registerObserver(onChange: () -> Unit): AutoCloseable {
follow(onChange) { it.registerObserver(onChange) }
override fun registerTaskObserver(onChange: () -> Unit): AutoCloseable =
follow(onChange) { it.registerTaskObserver(onChange) }
private fun follow(onChange: () -> Unit, register: (TasksDataSource) -> AutoCloseable): AutoCloseable {
val lock = Any() val lock = Any()
var closed = false var closed = false
var handle: AutoCloseable? = runCatching { register(active()) }.getOrNull() var handle: AutoCloseable? = runCatching { active().registerObserver(onChange) }.getOrNull()
val modeHandle = resolver.onModeChanged { val modeHandle = resolver.onModeChanged {
synchronized(lock) { synchronized(lock) {
if (!closed) { if (!closed) {
handle?.let { runCatching { it.close() } } handle?.let { runCatching { it.close() } }
handle = runCatching { register(active()) }.getOrNull() handle = runCatching { active().registerObserver(onChange) }.getOrNull()
} }
} }
onChange() onChange()
@@ -1,39 +0,0 @@
package de.jeanlucmakiola.agendula.data.tasks
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.launch
import java.util.concurrent.atomic.AtomicBoolean
/**
* Runs [action] once per burst of store changes: [register] hooks the store's
* observer, and [request] asks for a pass without one. Started at most once.
*/
class StoreChangeDebouncer(
private val scope: CoroutineScope,
private val delayMillis: Long,
private val register: (onChange: () -> Unit) -> AutoCloseable,
private val action: suspend () -> Unit,
) {
private val requests = Channel<Unit>(Channel.CONFLATED)
private val started = AtomicBoolean(false)
/** @return whether this call started it; false when it already was. */
@OptIn(FlowPreview::class)
fun start(): Boolean {
if (!started.compareAndSet(false, true)) return false
runCatching { register { requests.trySend(Unit) } }
scope.launch(Dispatchers.IO) {
requests.receiveAsFlow().debounce(delayMillis).collect { runCatching { action() } }
}
return true
}
fun request() {
requests.trySend(Unit)
}
}
@@ -5,7 +5,6 @@ import de.jeanlucmakiola.agendula.data.reminders.ReminderPlanner
import de.jeanlucmakiola.agendula.domain.Task import de.jeanlucmakiola.agendula.domain.Task
import de.jeanlucmakiola.agendula.domain.TaskForm import de.jeanlucmakiola.agendula.domain.TaskForm
import de.jeanlucmakiola.agendula.domain.TaskReminder import de.jeanlucmakiola.agendula.domain.TaskReminder
import de.jeanlucmakiola.agendula.domain.calendarDate
import java.time.ZoneId import java.time.ZoneId
import kotlin.time.Instant import kotlin.time.Instant
@@ -23,8 +22,7 @@ internal object StoredReminders {
* *
* A task getting its first date takes the defaults only when the form * A task getting its first date takes the defaults only when the form
* names no reminder; one that already had a date takes those of the new * names no reminder; one that already had a date takes those of the new
* date that no reminder of the form counts from, and those of a date * date that no reminder of the form counts from. Otherwise the form is
* whose defaults had all rung when it moved — see [revived]. Otherwise the form is
* kept, so an edit that clears every reminder means "use the default * kept, so an edit that clears every reminder means "use the default
* here" as before. * here" as before.
* *
@@ -35,7 +33,7 @@ internal object StoredReminders {
fun of(form: TaskForm, current: Task?, settings: Settings, now: Long, zone: ZoneId): List<TaskReminder> { fun of(form: TaskForm, current: Task?, settings: Settings, now: Long, zone: ZoneId): List<TaskReminder> {
val wanted = form.reminders.map { it.lead }.distinct() val wanted = form.reminders.map { it.lead }.distinct()
if (form.remindersChosen) return wanted if (form.remindersChosen) return wanted
return withDefaults(form, current?.start, current?.due, current?.isAllDay ?: form.isAllDay, settings, now, zone) return withDefaults(form, current?.start, current?.due, settings, now, zone)
} }
/** [of] for a task whose dates were [start] and [due], whatever the form chose. */ /** [of] for a task whose dates were [start] and [due], whatever the form chose. */
@@ -43,7 +41,6 @@ internal object StoredReminders {
form: TaskForm, form: TaskForm,
start: Instant?, start: Instant?,
due: Instant?, due: Instant?,
wasAllDay: Boolean,
settings: Settings, settings: Settings,
now: Long, now: Long,
zone: ZoneId, zone: ZoneId,
@@ -51,12 +48,10 @@ internal object StoredReminders {
val wanted = form.reminders.map { it.lead }.distinct() val wanted = form.reminders.map { it.lead }.distinct()
val had = anchorsOf(start, due) val had = anchorsOf(start, due)
val gained = anchorsOf(form.start, form.due) - had val gained = anchorsOf(form.start, form.due) - had
val revived = revived(form, start, due, wasAllDay, wanted, settings, now, zone) if (gained.isEmpty()) return wanted
if (gained.isEmpty() && revived.isEmpty()) return wanted
if (had.isEmpty() || wanted.isEmpty()) return wanted.ifEmpty { defaultsFor(form, settings, now, zone) } if (had.isEmpty() || wanted.isEmpty()) return wanted.ifEmpty { defaultsFor(form, settings, now, zone) }
val named = wanted.map { it.fromStart }.toSet() val named = wanted.map { it.fromStart }.toSet()
val fresh = gained + revived return wanted + defaultsFor(form, settings, now, zone).filter { it.fromStart in gained && it.fromStart !in named }
return wanted + defaultsFor(form, settings, now, zone).filter { it.fromStart in fresh && it.fromStart !in named }
} }
/** /**
@@ -84,41 +79,6 @@ internal object StoredReminders {
return kept.distinct().ifEmpty { defaultsFor(form, settings, now, zone) } return kept.distinct().ifEmpty { defaultsFor(form, settings, now, zone) }
} }
/**
* The dates of a one-off task that moved after every default of theirs
* had rung: a task saved then was left without them as past (#121), so
* the new date brings them like a date the task gains. Only on a task
* with reminders of its own and none from that date — no reminders at
* all is the planner's default or turned off, and either holds.
*/
private fun revived(
form: TaskForm,
start: Instant?,
due: Instant?,
wasAllDay: Boolean,
wanted: List<TaskReminder>,
settings: Settings,
now: Long,
zone: ZoneId,
): Set<Boolean> {
if (form.rrule != null || wanted.isEmpty()) return emptySet()
val named = wanted.map { it.fromStart }.toSet()
val defaults = settings.defaultRemindersFor(form.listId, wasAllDay)
fun revived(fromStart: Boolean, was: Instant?, isNow: Instant?): Boolean {
if (was == null || isNow == null || fromStart in named) return false
val moved = if (wasAllDay == form.isAllDay) {
isNow != was
} else {
isNow.calendarDate(form.isAllDay, zone) != was.calendarDate(wasAllDay, zone)
}
val leads = defaults.filter { it.fromStart == fromStart }
return moved && leads.isNotEmpty() && leads.all {
ReminderPlanner.triggerAt(was, wasAllDay, it.minutesBefore, settings.allDayReminderMinuteOfDay, zone) < now
}
}
return setOfNotNull(true.takeIf { revived(true, start, form.start) }, false.takeIf { revived(false, due, form.due) })
}
/** The dates a reminder can count from, as [TaskReminder.fromStart]. */ /** The dates a reminder can count from, as [TaskReminder.fromStart]. */
private fun anchorsOf(start: Any?, due: Any?): Set<Boolean> = private fun anchorsOf(start: Any?, due: Any?): Set<Boolean> =
setOfNotNull(true.takeIf { start != null }, false.takeIf { due != null }) setOfNotNull(true.takeIf { start != null }, false.takeIf { due != null })
@@ -152,7 +152,4 @@ interface TasksDataSource {
/** Observe any change to tasks/lists; [onChange] fires on a background thread. */ /** Observe any change to tasks/lists; [onChange] fires on a background thread. */
fun registerObserver(onChange: () -> Unit): AutoCloseable fun registerObserver(onChange: () -> Unit): AutoCloseable
/** Like [registerObserver], but only for changes to tasks, lists and alarms — not account bookkeeping. */
fun registerTaskObserver(onChange: () -> Unit): AutoCloseable = registerObserver(onChange)
} }
@@ -1,7 +1,8 @@
package de.jeanlucmakiola.agendula.data.tasks.provider package de.jeanlucmakiola.agendula.data.tasks.provider
import android.database.sqlite.SQLiteConstraintException import android.database.sqlite.SQLiteConstraintException
import de.jeanlucmakiola.agendula.data.reminders.ReminderRefresher import de.jeanlucmakiola.agendula.data.di.ApplicationScope
import de.jeanlucmakiola.agendula.data.reminders.ReminderScheduler
import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver
import de.jeanlucmakiola.agendula.data.tasks.StorageMode import de.jeanlucmakiola.agendula.data.tasks.StorageMode
import de.jeanlucmakiola.agendula.data.tasks.TaskWriteFailedException import de.jeanlucmakiola.agendula.data.tasks.TaskWriteFailedException
@@ -18,6 +19,8 @@ import de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase
import de.jeanlucmakiola.agendula.data.tasks.room.seriesOccurrences import de.jeanlucmakiola.agendula.data.tasks.room.seriesOccurrences
import de.jeanlucmakiola.agendula.domain.TaskStatus import de.jeanlucmakiola.agendula.domain.TaskStatus
import de.jeanlucmakiola.agendula.domain.normalizeTags import de.jeanlucmakiola.agendula.domain.normalizeTags
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import java.time.ZoneId import java.time.ZoneId
import java.util.UUID import java.util.UUID
import javax.inject.Inject import javax.inject.Inject
@@ -55,8 +58,9 @@ class ProviderWriter internal constructor(
store: RoomTasksDataSource, store: RoomTasksDataSource,
source: ProviderSnapshotSource, source: ProviderSnapshotSource,
resolver: ProviderResolver, resolver: ProviderResolver,
reminders: ReminderRefresher, scheduler: ReminderScheduler,
) : this(database, store, source, resolver, reminders::requestRefresh) @ApplicationScope scope: CoroutineScope,
) : this(database, store, source, resolver, { scope.launch { runCatching { scheduler.sync() } } })
private var batchDepth = 0 private var batchDepth = 0
private var batchWrote = false private var batchWrote = false
@@ -37,9 +37,6 @@ interface AccountDao {
@Query("UPDATE accounts SET last_sync_at = :at, last_sync_error = :error WHERE id = :accountId") @Query("UPDATE accounts SET last_sync_at = :at, last_sync_error = :error WHERE id = :accountId")
fun recordSync(accountId: Long, at: Instant?, error: String?) fun recordSync(accountId: Long, at: Instant?, error: String?)
@Query("UPDATE accounts SET label = :label WHERE id = :accountId")
fun setLabel(accountId: Long, label: String?)
@Query("DELETE FROM accounts WHERE id = :accountId") @Query("DELETE FROM accounts WHERE id = :accountId")
fun delete(accountId: Long): Int fun delete(accountId: Long): Int
} }
@@ -26,8 +26,6 @@ data class AccountEntity(
@ColumnInfo(name = "username") val username: String? = null, @ColumnInfo(name = "username") val username: String? = null,
@ColumnInfo(name = "last_sync_at") val lastSyncAt: Instant? = null, @ColumnInfo(name = "last_sync_at") val lastSyncAt: Instant? = null,
@ColumnInfo(name = "last_sync_error") val lastSyncError: String? = null, @ColumnInfo(name = "last_sync_error") val lastSyncError: String? = null,
/** A name the user gave the account (#105); [displayName] stays the stable key. */
@ColumnInfo(name = "label") val label: String? = null,
) )
/** /**
@@ -11,7 +11,6 @@ import androidx.room.Embedded
data class TaskListRow( data class TaskListRow(
@Embedded val list: TaskListEntity, @Embedded val list: TaskListEntity,
@ColumnInfo(name = "account_display_name") val accountDisplayName: String?, @ColumnInfo(name = "account_display_name") val accountDisplayName: String?,
@ColumnInfo(name = "account_label") val accountLabel: String? = null,
) )
/** /**
@@ -24,6 +23,5 @@ data class TaskRow(
@ColumnInfo(name = "list_name") val listName: String, @ColumnInfo(name = "list_name") val listName: String,
@ColumnInfo(name = "list_color") val listColor: Int, @ColumnInfo(name = "list_color") val listColor: Int,
@ColumnInfo(name = "account_display_name") val accountDisplayName: String?, @ColumnInfo(name = "account_display_name") val accountDisplayName: String?,
@ColumnInfo(name = "account_label") val accountLabel: String? = null,
@ColumnInfo(name = "list_read_only") val listReadOnly: Boolean = false, @ColumnInfo(name = "list_read_only") val listReadOnly: Boolean = false,
) )
@@ -20,7 +20,7 @@ object RoomTaskMapper {
color = row.list.color, color = row.list.color,
// TaskList.accountName is non-null and the lists screen groups by it, so a // TaskList.accountName is non-null and the lists screen groups by it, so a
// list with no account still has to report something to group under. // list with no account still has to report something to group under.
accountName = row.accountLabel ?: row.accountDisplayName ?: LocalAccount.NAME, accountName = row.accountDisplayName ?: LocalAccount.NAME,
accountType = if (row.list.accountId == null) LocalAccount.TYPE else CALDAV_ACCOUNT_TYPE, accountType = if (row.list.accountId == null) LocalAccount.TYPE else CALDAV_ACCOUNT_TYPE,
isSynced = row.list.isSynced, isSynced = row.list.isSynced,
isVisible = row.list.isVisible, isVisible = row.list.isVisible,
@@ -61,7 +61,7 @@ object RoomTaskMapper {
listColor = row.listColor, listColor = row.listColor,
taskColor = row.task.color, taskColor = row.task.color,
listName = row.listName, listName = row.listName,
accountName = row.accountLabel ?: row.accountDisplayName ?: LocalAccount.NAME, accountName = row.accountDisplayName ?: LocalAccount.NAME,
parentId = row.task.parentId, parentId = row.task.parentId,
isRecurring = row.task.isRecurring, isRecurring = row.task.isRecurring,
occurrenceStart = occurrenceStart, occurrenceStart = occurrenceStart,
@@ -76,7 +76,6 @@ internal fun seriesOccurrences(master: TaskEntity, overrides: List<TaskEntity>,
} }
internal val OBSERVED_TABLES = arrayOf("tasks", "task_lists", "task_alarms", "accounts") internal val OBSERVED_TABLES = arrayOf("tasks", "task_lists", "task_alarms", "accounts")
private val TASK_TABLES = arrayOf("tasks", "task_lists", "task_alarms")
/** Distinct alarm texts whose `ACKNOWLEDGED` is remembered between scheduler passes. */ /** Distinct alarm texts whose `ACKNOWLEDGED` is remembered between scheduler passes. */
private const val ACK_CACHE_SIZE = 512 private const val ACK_CACHE_SIZE = 512
@@ -651,12 +650,8 @@ class RoomTasksDataSource @Inject constructor(
// --- observation ---------------------------------------------------------- // --- observation ----------------------------------------------------------
override fun registerObserver(onChange: () -> Unit): AutoCloseable = observe(OBSERVED_TABLES, onChange) override fun registerObserver(onChange: () -> Unit): AutoCloseable {
val observer = object : InvalidationTracker.Observer(OBSERVED_TABLES) {
override fun registerTaskObserver(onChange: () -> Unit): AutoCloseable = observe(TASK_TABLES, onChange)
private fun observe(tables: Array<String>, onChange: () -> Unit): AutoCloseable {
val observer = object : InvalidationTracker.Observer(tables) {
override fun onInvalidated(tables: Set<String>) = onChange() override fun onInvalidated(tables: Set<String>) = onChange()
} }
database.invalidationTracker.addObserver(observer) database.invalidationTracker.addObserver(observer)
@@ -29,7 +29,7 @@ interface TaskDao {
@Query( @Query(
""" """
SELECT t.*, l.name AS list_name, l.color AS list_color, l.is_read_only AS list_read_only, SELECT t.*, l.name AS list_name, l.color AS list_color, l.is_read_only AS list_read_only,
a.display_name AS account_display_name, a.label AS account_label a.display_name AS account_display_name
FROM tasks t FROM tasks t
JOIN task_lists l ON l.id = t.list_id JOIN task_lists l ON l.id = t.list_id
LEFT JOIN accounts a ON a.id = l.account_id LEFT JOIN accounts a ON a.id = l.account_id
@@ -44,7 +44,7 @@ interface TaskDao {
@Query( @Query(
""" """
SELECT t.*, l.name AS list_name, l.color AS list_color, l.is_read_only AS list_read_only, SELECT t.*, l.name AS list_name, l.color AS list_color, l.is_read_only AS list_read_only,
a.display_name AS account_display_name, a.label AS account_label a.display_name AS account_display_name
FROM tasks t FROM tasks t
JOIN task_lists l ON l.id = t.list_id JOIN task_lists l ON l.id = t.list_id
LEFT JOIN accounts a ON a.id = l.account_id LEFT JOIN accounts a ON a.id = l.account_id
@@ -56,7 +56,7 @@ interface TaskDao {
@Query( @Query(
""" """
SELECT t.*, l.name AS list_name, l.color AS list_color, l.is_read_only AS list_read_only, SELECT t.*, l.name AS list_name, l.color AS list_color, l.is_read_only AS list_read_only,
a.display_name AS account_display_name, a.label AS account_label a.display_name AS account_display_name
FROM tasks t FROM tasks t
JOIN task_lists l ON l.id = t.list_id JOIN task_lists l ON l.id = t.list_id
LEFT JOIN accounts a ON a.id = l.account_id LEFT JOIN accounts a ON a.id = l.account_id
@@ -16,7 +16,7 @@ interface TaskListDao {
@Query( @Query(
""" """
SELECT l.*, a.display_name AS account_display_name, a.label AS account_label SELECT l.*, a.display_name AS account_display_name
FROM task_lists l LEFT JOIN accounts a ON a.id = l.account_id FROM task_lists l LEFT JOIN accounts a ON a.id = l.account_id
ORDER BY a.display_name, l.sort_order, l.name ORDER BY a.display_name, l.sort_order, l.name
""" """
@@ -25,7 +25,7 @@ interface TaskListDao {
@Query( @Query(
""" """
SELECT l.*, a.display_name AS account_display_name, a.label AS account_label SELECT l.*, a.display_name AS account_display_name
FROM task_lists l LEFT JOIN accounts a ON a.id = l.account_id FROM task_lists l LEFT JOIN accounts a ON a.id = l.account_id
WHERE l.id = :listId WHERE l.id = :listId
""" """
@@ -22,7 +22,7 @@ import kotlin.time.Instant
TaskEntity::class, TaskEntity::class,
TaskAlarmEntity::class, TaskAlarmEntity::class,
], ],
version = 4, version = 3,
exportSchema = true, exportSchema = true,
) )
@TypeConverters(Converters::class) @TypeConverters(Converters::class)
@@ -173,12 +173,5 @@ abstract class TasksDatabase : RoomDatabase() {
) )
} }
} }
/** v4 lets the user name an account (#105). */
val MIGRATION_3_4 = object : Migration(3, 4) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE accounts ADD COLUMN label TEXT")
}
}
} }
} }
@@ -59,7 +59,7 @@ fun dueWindowOf(
todayEnd: Instant, todayEnd: Instant,
zone: ZoneId = ZoneId.systemDefault(), zone: ZoneId = ZoneId.systemDefault(),
): DueWindow { ): DueWindow {
val due = task.wallClock(task.due, zone) val due = task.due?.let { if (task.isAllDay) it.rebasedForAllDay(allDay = false, zone = zone) else it }
return when { return when {
due == null -> DueWindow.NO_DATE due == null -> DueWindow.NO_DATE
due < todayStart -> DueWindow.OVERDUE due < todayStart -> DueWindow.OVERDUE
@@ -68,10 +68,6 @@ fun dueWindowOf(
} }
} }
/** [at], one of this task's dates, as a local instant: an all-day date is read as its calendar date. */
fun Task.wallClock(at: Instant?, zone: ZoneId = ZoneId.systemDefault()): Instant? =
at?.let { if (isAllDay) it.rebasedForAllDay(allDay = false, zone = zone) else it }
/** What a task created from a [SavedFilter] starts with, so it lands in that filter. */ /** What a task created from a [SavedFilter] starts with, so it lands in that filter. */
data class NewTaskPreset( data class NewTaskPreset(
val listIds: Set<Long> = emptySet(), val listIds: Set<Long> = emptySet(),
@@ -1,13 +1,8 @@
package de.jeanlucmakiola.agendula.domain package de.jeanlucmakiola.agendula.domain
import java.time.ZoneId
import kotlin.time.Instant import kotlin.time.Instant
/** /** The built-in "smart" lists, computed from due dates rather than membership. */
* The built-in "smart" lists, computed from dates rather than membership. All
* but [COMPLETED] hold open tasks, except [TODAY], which also keeps the ones
* completed today (see [TaskFiltering.isToday]).
*/
enum class SmartList { ALL, TODAY, UPCOMING, OVERDUE, NO_DATE, COMPLETED } enum class SmartList { ALL, TODAY, UPCOMING, OVERDUE, NO_DATE, COMPLETED }
/** What a task screen is showing: one real list, a smart list, or a tag. */ /** What a task screen is showing: one real list, a smart list, or a tag. */
@@ -45,20 +40,6 @@ object TaskFiltering {
is TaskFilter.Saved -> saved?.takeIf { it.id == filter.id }?.matches(task, todayStart, todayEnd) ?: false is TaskFilter.Saved -> saved?.takeIf { it.id == filter.id }?.matches(task, todayStart, todayEnd) ?: false
} }
/**
* Whether [task] belongs in Today. An open task does when it is due today, or
* starts today without being overdue; a completed one when it was completed today.
*/
fun isToday(task: Task, todayStart: Instant, todayEnd: Instant, zone: ZoneId = ZoneId.systemDefault()): Boolean {
fun inToday(at: Instant?) = at != null && at >= todayStart && at < todayEnd
if (task.isClosed) return task.isCompleted && inToday(task.completedAt)
return when (dueWindowOf(task, todayStart, todayEnd, zone)) {
DueWindow.TODAY -> true
DueWindow.OVERDUE -> false
DueWindow.UPCOMING, DueWindow.NO_DATE -> inToday(task.wallClock(task.start, zone))
}
}
/** Whether [task] carries every one of [tags]; an empty set matches everything. */ /** Whether [task] carries every one of [tags]; an empty set matches everything. */
fun hasAllTags(task: Task, tags: Set<String>): Boolean = task.tags.containsAll(tags) fun hasAllTags(task: Task, tags: Set<String>): Boolean = task.tags.containsAll(tags)
@@ -68,7 +49,7 @@ object TaskFiltering {
SmartList.ALL -> !task.isClosed SmartList.ALL -> !task.isClosed
SmartList.NO_DATE -> !task.isClosed && dueWindowOf(task, todayStart, todayEnd) == DueWindow.NO_DATE SmartList.NO_DATE -> !task.isClosed && dueWindowOf(task, todayStart, todayEnd) == DueWindow.NO_DATE
SmartList.OVERDUE -> !task.isClosed && dueWindowOf(task, todayStart, todayEnd) == DueWindow.OVERDUE SmartList.OVERDUE -> !task.isClosed && dueWindowOf(task, todayStart, todayEnd) == DueWindow.OVERDUE
SmartList.TODAY -> isToday(task, todayStart, todayEnd) SmartList.TODAY -> !task.isClosed && dueWindowOf(task, todayStart, todayEnd) == DueWindow.TODAY
SmartList.UPCOMING -> !task.isClosed && dueWindowOf(task, todayStart, todayEnd) == DueWindow.UPCOMING SmartList.UPCOMING -> !task.isClosed && dueWindowOf(task, todayStart, todayEnd) == DueWindow.UPCOMING
} }
} }
@@ -16,22 +16,19 @@ data class SectionedTasks(val section: TaskSection, val tasks: List<Task>)
* *
* [todayStart] is local midnight today and [todayEnd] local midnight tomorrow * [todayStart] is local midnight today and [todayEnd] local midnight tomorrow
* (compute with [DayWindow]). Only non-empty sections are returned, in the * (compute with [DayWindow]). Only non-empty sections are returned, in the
* fixed order Overdue → Today → Upcoming → No date → Completed. In the Today * fixed order Overdue → Today → Upcoming → No date → Completed.
* list ([todayList]) every open task sits under Today, as it may be there by its
* start date alone.
*/ */
object TaskSections { object TaskSections {
fun of(tasks: List<Task>, todayStart: Instant, todayEnd: Instant, todayList: Boolean = false): List<SectionedTasks> { fun of(tasks: List<Task>, todayStart: Instant, todayEnd: Instant): List<SectionedTasks> {
val bySection = tasks.groupBy { sectionOf(it, todayStart, todayEnd, todayList) } val bySection = tasks.groupBy { sectionOf(it, todayStart, todayEnd) }
return TaskSection.entries.mapNotNull { section -> return TaskSection.entries.mapNotNull { section ->
bySection[section]?.takeIf { it.isNotEmpty() }?.let { SectionedTasks(section, it) } bySection[section]?.takeIf { it.isNotEmpty() }?.let { SectionedTasks(section, it) }
} }
} }
fun sectionOf(task: Task, todayStart: Instant, todayEnd: Instant, todayList: Boolean = false): TaskSection { fun sectionOf(task: Task, todayStart: Instant, todayEnd: Instant): TaskSection {
if (task.isCompleted) return TaskSection.COMPLETED if (task.isCompleted) return TaskSection.COMPLETED
if (todayList) return TaskSection.TODAY
return when (dueWindowOf(task, todayStart, todayEnd)) { return when (dueWindowOf(task, todayStart, todayEnd)) {
DueWindow.OVERDUE -> TaskSection.OVERDUE DueWindow.OVERDUE -> TaskSection.OVERDUE
DueWindow.TODAY -> TaskSection.TODAY DueWindow.TODAY -> TaskSection.TODAY
@@ -46,7 +46,6 @@ fun RootScreen(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
navRequest: NavRequest? = null, navRequest: NavRequest? = null,
onNavRequestConsumed: () -> Unit = {}, onNavRequestConsumed: () -> Unit = {},
onReturnHome: () -> Unit = {},
permissionViewModel: PermissionViewModel = hiltViewModel(), permissionViewModel: PermissionViewModel = hiltViewModel(),
) { ) {
val permission by permissionViewModel.state.collectAsStateWithLifecycle() val permission by permissionViewModel.state.collectAsStateWithLifecycle()
@@ -87,7 +86,6 @@ fun RootScreen(
modifier = modifier, modifier = modifier,
navRequest = navRequest, navRequest = navRequest,
onNavRequestConsumed = onNavRequestConsumed, onNavRequestConsumed = onNavRequestConsumed,
onReturnHome = onReturnHome,
) )
} }
} }
@@ -103,7 +101,6 @@ private fun ReadyGate(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
navRequest: NavRequest? = null, navRequest: NavRequest? = null,
onNavRequestConsumed: () -> Unit = {}, onNavRequestConsumed: () -> Unit = {},
onReturnHome: () -> Unit = {},
onboardingViewModel: OnboardingViewModel = hiltViewModel(), onboardingViewModel: OnboardingViewModel = hiltViewModel(),
) { ) {
val done by onboardingViewModel.done.collectAsStateWithLifecycle() val done by onboardingViewModel.done.collectAsStateWithLifecycle()
@@ -116,7 +113,6 @@ private fun ReadyGate(
modifier = modifier.fillMaxSize().background(MaterialTheme.colorScheme.surface), modifier = modifier.fillMaxSize().background(MaterialTheme.colorScheme.surface),
navRequest = navRequest, navRequest = navRequest,
onNavRequestConsumed = onNavRequestConsumed, onNavRequestConsumed = onNavRequestConsumed,
onReturnHome = onReturnHome,
) )
false -> ReadableWidth(modifier) { false -> ReadableWidth(modifier) {
OnboardingFlow(modifier = Modifier.fillMaxSize(), viewModel = onboardingViewModel) OnboardingFlow(modifier = Modifier.fillMaxSize(), viewModel = onboardingViewModel)
@@ -1,66 +0,0 @@
package de.jeanlucmakiola.agendula.ui.common
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.systemGestures
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.pointer.PointerEventPass
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.positionInWindow
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.platform.LocalWindowInfo
import androidx.compose.ui.unit.dp
import kotlin.math.abs
/** How far past the system back-gesture zone a horizontal swipe is still ignored. */
private val EDGE_MARGIN = 24.dp
/**
* Swallows horizontal swipes that start near the screen's left or right edge,
* before a swipe box below it sees them (#90). A back gesture started just
* inside the system's gesture zone otherwise reaches the app as a row swipe.
* Taps and vertical scrolls are left alone.
*/
@Composable
fun Modifier.ignoringEdgeSwipes(): Modifier {
val density = LocalDensity.current
val layoutDirection = LocalLayoutDirection.current
val windowWidth = LocalWindowInfo.current.containerSize.width
val insets = WindowInsets.systemGestures
val margin = with(density) { EDGE_MARGIN.roundToPx() }
val leftEdge = insets.getLeft(density, layoutDirection) + margin
val rightEdge = windowWidth - insets.getRight(density, layoutDirection) - margin
var offsetX by remember { mutableFloatStateOf(0f) }
return this
.onGloballyPositioned { offsetX = it.positionInWindow().x }
.pointerInput(leftEdge, rightEdge) {
val slop = viewConfiguration.touchSlop
awaitEachGesture {
val down = awaitFirstDown(requireUnconsumed = false, pass = PointerEventPass.Initial)
val x = offsetX + down.position.x
if (x > leftEdge && x < rightEdge) return@awaitEachGesture
var horizontal = false
while (true) {
val change = awaitPointerEvent(PointerEventPass.Initial).changes
.firstOrNull { it.id == down.id } ?: break
if (!horizontal) {
val delta = change.position - down.position
when {
abs(delta.x) > slop -> horizontal = true
abs(delta.y) > slop -> break
}
}
if (horizontal) change.consume()
if (!change.pressed) break
}
}
}
}
@@ -1,51 +0,0 @@
package de.jeanlucmakiola.agendula.ui.common
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.material3.pulltorefresh.PullToRefreshDefaults
import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
/**
* Pull down to sync every account (#73). Without an account there is nothing
* to sync, so the gesture is off. [topPadding] keeps the indicator below a top
* bar the content scrolls under.
*/
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun SyncPullBox(
available: Boolean,
syncing: Boolean,
onSync: () -> Unit,
modifier: Modifier = Modifier,
topPadding: Dp = 0.dp,
content: @Composable BoxScope.() -> Unit,
) {
if (!available) {
Box(modifier, content = content)
return
}
val state = rememberPullToRefreshState()
PullToRefreshBox(
isRefreshing = syncing,
onRefresh = onSync,
modifier = modifier,
state = state,
indicator = {
PullToRefreshDefaults.LoadingIndicator(
state = state,
isRefreshing = syncing,
modifier = Modifier.align(Alignment.TopCenter).padding(top = topPadding),
)
},
content = content,
)
}
@@ -6,6 +6,7 @@ import dagger.hilt.android.lifecycle.HiltViewModel
import de.jeanlucmakiola.agendula.data.prefs.DEFAULT_ALL_DAY_REMINDER_MINUTE import de.jeanlucmakiola.agendula.data.prefs.DEFAULT_ALL_DAY_REMINDER_MINUTE
import de.jeanlucmakiola.agendula.data.prefs.Settings import de.jeanlucmakiola.agendula.data.prefs.Settings
import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.agendula.data.reminders.ReminderScheduler
import de.jeanlucmakiola.agendula.data.tasks.TaskConflictException import de.jeanlucmakiola.agendula.data.tasks.TaskConflictException
import de.jeanlucmakiola.agendula.data.tasks.TasksRepository import de.jeanlucmakiola.agendula.data.tasks.TasksRepository
import de.jeanlucmakiola.agendula.domain.Priority import de.jeanlucmakiola.agendula.domain.Priority
@@ -108,6 +109,7 @@ data class TaskEditUiState(
class TaskEditViewModel @Inject constructor( class TaskEditViewModel @Inject constructor(
private val repository: TasksRepository, private val repository: TasksRepository,
private val settingsPrefs: SettingsPrefs, private val settingsPrefs: SettingsPrefs,
private val reminderScheduler: ReminderScheduler,
) : ViewModel() { ) : ViewModel() {
private val _state = MutableStateFlow(TaskEditUiState()) private val _state = MutableStateFlow(TaskEditUiState())
@@ -147,7 +149,6 @@ class TaskEditViewModel @Inject constructor(
private var loadedReminders: List<TaskReminder> = emptyList() private var loadedReminders: List<TaskReminder> = emptyList()
private var loadedStart: Instant? = null private var loadedStart: Instant? = null
private var loadedDue: Instant? = null private var loadedDue: Instant? = null
private var loadedAllDay = false
/** Start a fresh task, optionally pre-selecting a list / parent. */ /** Start a fresh task, optionally pre-selecting a list / parent. */
fun bindNew( fun bindNew(
@@ -156,7 +157,6 @@ class TaskEditViewModel @Inject constructor(
initialTitle: String? = null, initialTitle: String? = null,
presetTag: String? = null, presetTag: String? = null,
presetFilterId: Long? = null, presetFilterId: Long? = null,
presetDueToday: Boolean = false,
) { ) {
if (bound) return if (bound) return
bound = true bound = true
@@ -169,7 +169,6 @@ class TaskEditViewModel @Inject constructor(
val lists = runCatching { repository.taskLists().first() }.getOrElse { emptyList() } val lists = runCatching { repository.taskLists().first() }.getOrElse { emptyList() }
val preset = presetFilterId?.let { settingsPrefs.savedFilter(it).first() } val preset = presetFilterId?.let { settingsPrefs.savedFilter(it).first() }
?.newTaskPreset(LocalDate.now()) ?.newTaskPreset(LocalDate.now())
val presetDue = preset?.dueDate ?: LocalDate.now().takeIf { presetDueToday }
// A read-only or vanished default — or a widget's preset list, if it was // 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 // since deleted or turned read-only — would give the task a list it
// cannot be saved to. // cannot be saved to.
@@ -191,12 +190,12 @@ class TaskEditViewModel @Inject constructor(
// today's start would land after a due of today. // today's start would land after a due of today.
start = when { start = when {
!settings.defaultStartToday -> null !settings.defaultStartToday -> null
presetDue != null -> allDayInstantOf(LocalDate.now()) preset?.dueDate != null -> allDayInstantOf(LocalDate.now())
else -> Clock.System.now() else -> Clock.System.now()
}, },
// The same all-day value picking the date in the date field gives. // The same all-day value picking the date in the date field gives.
due = presetDue?.let(::allDayInstantOf), due = preset?.dueDate?.let(::allDayInstantOf),
isAllDay = presetDue != null, isAllDay = preset?.dueDate != null,
priority = preset?.priority ?: Priority.NONE, priority = preset?.priority ?: Priority.NONE,
parentId = parentId, parentId = parentId,
lists = lists, lists = lists,
@@ -229,7 +228,6 @@ class TaskEditViewModel @Inject constructor(
storedReminders = loadedReminders.toSet() storedReminders = loadedReminders.toSet()
loadedStart = task.start loadedStart = task.start
loadedDue = task.due loadedDue = task.due
loadedAllDay = task.isAllDay
_state.value = withFields( _state.value = withFields(
TaskEditUiState( TaskEditUiState(
loading = false, loading = false,
@@ -364,7 +362,6 @@ class TaskEditViewModel @Inject constructor(
toForm().copy(reminders = loadedReminders), toForm().copy(reminders = loadedReminders),
loadedStart, loadedStart,
loadedDue, loadedDue,
loadedAllDay,
settings, settings,
nowMillis(), nowMillis(),
ZoneId.systemDefault(), ZoneId.systemDefault(),
@@ -449,6 +446,13 @@ class TaskEditViewModel @Inject constructor(
scope = scope, scope = scope,
) )
} }
// ⚠️ Outside the write's own result. The task is saved by this
// point, and reporting a scheduling failure as a *save* failure
// sends the user back to tap Save again — on a screen whose
// editingTaskId is still null, which creates a second task. The
// reminder is re-synced on the next data change and on launch;
// the duplicate is forever.
runCatching { reminderScheduler.sync() }
}.onSuccess { }.onSuccess {
_state.value = _state.value.copy(saved = true, saveFailed = false, saveConflict = false) _state.value = _state.value.copy(saved = true, saveFailed = false, saveConflict = false)
}.onFailure { error -> }.onFailure { error ->
@@ -161,7 +161,7 @@ class ImportViewModel @Inject constructor(
val result = importer.import(ready.parsed, target) val result = importer.import(ready.parsed, target)
(chosen as? ImportChoice.Existing)?.list (chosen as? ImportChoice.Existing)?.list
?.takeIf { it.accountId != null && result.imported > 0 } ?.takeIf { it.accountId != null && result.imported > 0 }
?.let { runCatching { syncRequests.syncNow(it.accountId!!) } } ?.let { runCatching { syncRequests.syncNow(it.accountName) } }
ImportStep.Done( ImportStep.Done(
listName = listName, listName = listName,
imported = result.imported, imported = result.imported,
@@ -97,7 +97,6 @@ import kotlinx.coroutines.delay
import de.jeanlucmakiola.agendula.ui.common.ListColorChip import de.jeanlucmakiola.agendula.ui.common.ListColorChip
import de.jeanlucmakiola.agendula.ui.common.LocalUse24HourFormat import de.jeanlucmakiola.agendula.ui.common.LocalUse24HourFormat
import de.jeanlucmakiola.agendula.ui.common.ShapedActionButton import de.jeanlucmakiola.agendula.ui.common.ShapedActionButton
import de.jeanlucmakiola.agendula.ui.common.SyncPullBox
import de.jeanlucmakiola.agendula.ui.common.priorityAccent import de.jeanlucmakiola.agendula.ui.common.priorityAccent
import de.jeanlucmakiola.agendula.ui.tasklist.priorityLabel import de.jeanlucmakiola.agendula.ui.tasklist.priorityLabel
import de.jeanlucmakiola.floret.components.positionOf import de.jeanlucmakiola.floret.components.positionOf
@@ -123,8 +122,6 @@ fun ListsScreen(
viewModel: ListsViewModel = hiltViewModel(), viewModel: ListsViewModel = hiltViewModel(),
) { ) {
val state by viewModel.state.collectAsStateWithLifecycle() val state by viewModel.state.collectAsStateWithLifecycle()
val syncAvailable by viewModel.syncAvailable.collectAsStateWithLifecycle()
val syncing by viewModel.syncing.collectAsStateWithLifecycle()
var query by rememberSaveable { mutableStateOf("") } var query by rememberSaveable { mutableStateOf("") }
var searchActive by rememberSaveable { mutableStateOf(false) } var searchActive by rememberSaveable { mutableStateOf(false) }
var newList by rememberSaveable { mutableStateOf(false) } var newList by rememberSaveable { mutableStateOf(false) }
@@ -176,12 +173,7 @@ fun ListsScreen(
} }
}, },
) { inner -> ) { inner ->
SyncPullBox( Box(Modifier.fillMaxSize().padding(top = inner.calculateTopPadding())) {
available = syncAvailable && !searchActive,
syncing = syncing,
onSync = viewModel::syncAll,
modifier = Modifier.fillMaxSize().padding(top = inner.calculateTopPadding()),
) {
when (val s = state) { when (val s = state) {
ListsUiState.Loading -> Unit // brief; avoids a flash before first emission ListsUiState.Loading -> Unit // brief; avoids a flash before first emission
ListsUiState.Failure -> ListsUiState.Failure ->
@@ -4,7 +4,6 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.agendula.data.sync.ManualSync
import de.jeanlucmakiola.agendula.data.sync.RemoteLists import de.jeanlucmakiola.agendula.data.sync.RemoteLists
import de.jeanlucmakiola.agendula.data.tasks.TasksRepository import de.jeanlucmakiola.agendula.data.tasks.TasksRepository
import de.jeanlucmakiola.agendula.data.tasks.recoveringFromProviderFailure import de.jeanlucmakiola.agendula.data.tasks.recoveringFromProviderFailure
@@ -106,17 +105,9 @@ private const val UPCOMING_PREVIEW = 3
class ListsViewModel @Inject constructor( class ListsViewModel @Inject constructor(
private val repository: TasksRepository, private val repository: TasksRepository,
private val remoteLists: RemoteLists, private val remoteLists: RemoteLists,
private val manualSync: ManualSync,
settingsPrefs: SettingsPrefs, settingsPrefs: SettingsPrefs,
) : ViewModel() { ) : ViewModel() {
/** Pull to sync (#73); off without an account. */
val syncAvailable: StateFlow<Boolean> =
manualSync.available.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false)
val syncing: StateFlow<Boolean> = manualSync.syncing
fun syncAll() = manualSync.syncAll()
/** /**
* Where a new list may go: the device, then every account whose server said * Where a new list may go: the device, then every account whose server said
* it would take one. * it would take one.
@@ -172,12 +163,14 @@ class ListsViewModel @Inject constructor(
SmartCount(SmartList.ALL, smartTopLevel.size), SmartCount(SmartList.ALL, smartTopLevel.size),
) )
// Today ring: the completed rows of the Today list against all of its rows. // Today ring: completed vs. total tasks *due today*. The numerator is the
// top-level completed tasks whose due date falls in today's window; the
// denominator adds the still-open ones (the Today smart count above).
val openToday = count(SmartList.TODAY) val openToday = count(SmartList.TODAY)
val completedToday = completedTasks.count { val completedDueToday = completedTasks.count {
!it.isSubtask && it.listId !in hidden && TaskFiltering.isToday(it, todayStart, todayEnd) !it.isSubtask && it.listId !in hidden && it.due != null && it.due >= todayStart && it.due < todayEnd
} }
val todayTotal = openToday + completedToday val todayTotal = openToday + completedDueToday
// Upcoming preview: the next handful of open tasks due tomorrow onward, // Upcoming preview: the next handful of open tasks due tomorrow onward,
// already sorted by the repository's default ordering. // already sorted by the repository's default ordering.
@@ -212,7 +205,7 @@ class ListsViewModel @Inject constructor(
smartCounts = smartCounts, smartCounts = smartCounts,
filters = filters, filters = filters,
groups = groups, groups = groups,
todayDone = completedToday, todayDone = completedDueToday,
todayTotal = todayTotal, todayTotal = todayTotal,
upcoming = upcoming, upcoming = upcoming,
allTasks = allTasks, allTasks = allTasks,
@@ -1,6 +1,5 @@
package de.jeanlucmakiola.agendula.ui.navigation package de.jeanlucmakiola.agendula.ui.navigation
import android.annotation.SuppressLint
import androidx.compose.animation.EnterTransition import androidx.compose.animation.EnterTransition
import androidx.compose.animation.ExitTransition import androidx.compose.animation.ExitTransition
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
@@ -8,17 +7,11 @@ import androidx.compose.animation.slideInHorizontally
import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.setValue
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.collectAsState
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntOffset
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import de.jeanlucmakiola.agendula.domain.SmartList
import de.jeanlucmakiola.agendula.domain.Task import de.jeanlucmakiola.agendula.domain.Task
import kotlin.time.Instant import kotlin.time.Instant
import androidx.navigation.compose.NavHost import androidx.navigation.compose.NavHost
@@ -53,21 +46,8 @@ fun AgendulaNavHost(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
navRequest: NavRequest? = null, navRequest: NavRequest? = null,
onNavRequestConsumed: () -> Unit = {}, onNavRequestConsumed: () -> Unit = {},
onReturnHome: () -> Unit = {},
) { ) {
val nav = rememberNavController() val nav = rememberNavController()
// The entry a widget, tile, shortcut or share opened; closing it leaves the app (#93).
var returnHomeEntry by rememberSaveable { mutableStateOf<String?>(null) }
// No public API exposes the whole back stack, and visibleEntries drops an entry covered by another.
@SuppressLint("RestrictedApi")
val backStack by nav.currentBackStack.collectAsState()
LaunchedEffect(backStack, returnHomeEntry) {
val id = returnHomeEntry ?: return@LaunchedEffect
if (backStack.none { it.id == id }) {
returnHomeEntry = null
onReturnHome()
}
}
LaunchedEffect(navRequest) { LaunchedEffect(navRequest) {
when (navRequest) { when (navRequest) {
@@ -79,9 +59,7 @@ fun AgendulaNavHost(
is NavRequest.OpenAccount -> is NavRequest.OpenAccount ->
nav.navigate(Dest.Settings.buildAccount(navRequest.accountId)) { launchSingleTop = true } nav.navigate(Dest.Settings.buildAccount(navRequest.accountId)) { launchSingleTop = true }
is NavRequest.NewTask -> is NavRequest.NewTask ->
nav.navigate( nav.navigate(Dest.TaskEdit.buildNew(presetListId = navRequest.listId, title = navRequest.title))
Dest.TaskEdit.buildNew(presetListId = navRequest.listId, title = navRequest.title, dueToday = navRequest.dueToday),
)
is NavRequest.OpenSmart -> nav.navigate(Dest.TaskList.build(TaskFilter.Smart(navRequest.list))) { is NavRequest.OpenSmart -> nav.navigate(Dest.TaskList.build(TaskFilter.Smart(navRequest.list))) {
launchSingleTop = true launchSingleTop = true
} }
@@ -93,9 +71,6 @@ fun AgendulaNavHost(
} }
is NavRequest.Import -> nav.navigate(Dest.Import.build(navRequest.uri)) is NavRequest.Import -> nav.navigate(Dest.Import.build(navRequest.uri))
} }
val returnHome = (navRequest as? NavRequest.NewTask)?.returnHome == true ||
(navRequest as? NavRequest.OpenTask)?.returnHome == true
returnHomeEntry = if (returnHome) nav.currentBackStackEntry?.id else null
onNavRequestConsumed() onNavRequestConsumed()
} }
@@ -166,7 +141,6 @@ fun AgendulaNavHost(
presetListId = (filter as? TaskFilter.OfList)?.listId, presetListId = (filter as? TaskFilter.OfList)?.listId,
tag = (filter as? TaskFilter.Tagged)?.tag, tag = (filter as? TaskFilter.Tagged)?.tag,
filterId = (filter as? TaskFilter.Saved)?.id, filterId = (filter as? TaskFilter.Saved)?.id,
dueToday = filter == TaskFilter.Smart(SmartList.TODAY),
), ),
) )
}, },
@@ -238,7 +212,6 @@ fun AgendulaNavHost(
val title = args?.getString(Dest.TaskEdit.ARG_TITLE) val title = args?.getString(Dest.TaskEdit.ARG_TITLE)
val tag = args?.getString(Dest.TaskEdit.ARG_TAG) val tag = args?.getString(Dest.TaskEdit.ARG_TAG)
val filterId = args?.getLong(Dest.TaskEdit.ARG_FILTER_ID)?.takeIf { it != Dest.NO_ID } val filterId = args?.getLong(Dest.TaskEdit.ARG_FILTER_ID)?.takeIf { it != Dest.NO_ID }
val dueToday = args?.getBoolean(Dest.TaskEdit.ARG_DUE_TODAY) ?: false
val vm: TaskEditViewModel = hiltViewModel() val vm: TaskEditViewModel = hiltViewModel()
LaunchedEffect(taskId, presetListId, parentId) { LaunchedEffect(taskId, presetListId, parentId) {
if (taskId != Dest.NO_ID) { if (taskId != Dest.NO_ID) {
@@ -250,7 +223,6 @@ fun AgendulaNavHost(
initialTitle = title, initialTitle = title,
presetTag = tag, presetTag = tag,
presetFilterId = filterId, presetFilterId = filterId,
presetDueToday = dueToday,
) )
} }
} }
@@ -119,7 +119,7 @@ object Dest {
/** /**
* The create / edit form. `taskId` set ([NO_ID] otherwise) ⇒ edit; for a new * The create / edit form. `taskId` set ([NO_ID] otherwise) ⇒ edit; for a new
* task, `presetListId` / `parentId` optionally seed the form, and `filterId` * task, `presetListId` / `parentId` optionally seed the form, and `filterId`
* (or `dueToday`, for the Today list) fills it so the task lands in that view. * fills it so the task lands in that saved filter.
*/ */
object TaskEdit { object TaskEdit {
const val ARG_TASK_ID = "taskId" const val ARG_TASK_ID = "taskId"
@@ -129,7 +129,6 @@ object Dest {
const val ARG_TITLE = "title" const val ARG_TITLE = "title"
const val ARG_TAG = "tag" const val ARG_TAG = "tag"
const val ARG_FILTER_ID = "filterId" const val ARG_FILTER_ID = "filterId"
const val ARG_DUE_TODAY = "dueToday"
const val route = const val route =
"taskEdit?$ARG_TASK_ID={$ARG_TASK_ID}" + "taskEdit?$ARG_TASK_ID={$ARG_TASK_ID}" +
"&$ARG_PRESET_LIST_ID={$ARG_PRESET_LIST_ID}" + "&$ARG_PRESET_LIST_ID={$ARG_PRESET_LIST_ID}" +
@@ -137,8 +136,7 @@ object Dest {
"&$ARG_OCCURRENCE={$ARG_OCCURRENCE}" + "&$ARG_OCCURRENCE={$ARG_OCCURRENCE}" +
"&$ARG_TITLE={$ARG_TITLE}" + "&$ARG_TITLE={$ARG_TITLE}" +
"&$ARG_TAG={$ARG_TAG}" + "&$ARG_TAG={$ARG_TAG}" +
"&$ARG_FILTER_ID={$ARG_FILTER_ID}" + "&$ARG_FILTER_ID={$ARG_FILTER_ID}"
"&$ARG_DUE_TODAY={$ARG_DUE_TODAY}"
val arguments = listOf( val arguments = listOf(
navArgument(ARG_TASK_ID) { type = NavType.LongType; defaultValue = NO_ID }, navArgument(ARG_TASK_ID) { type = NavType.LongType; defaultValue = NO_ID },
@@ -148,7 +146,6 @@ object Dest {
navArgument(ARG_TITLE) { type = NavType.StringType; nullable = true; defaultValue = null }, navArgument(ARG_TITLE) { type = NavType.StringType; nullable = true; defaultValue = null },
navArgument(ARG_TAG) { 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 }, navArgument(ARG_FILTER_ID) { type = NavType.LongType; defaultValue = NO_ID },
navArgument(ARG_DUE_TODAY) { type = NavType.BoolType; defaultValue = false },
) )
fun buildEdit(taskId: Long, occurrenceStart: Long? = null): String = fun buildEdit(taskId: Long, occurrenceStart: Long? = null): String =
@@ -161,7 +158,6 @@ object Dest {
title: String? = null, title: String? = null,
tag: String? = null, tag: String? = null,
filterId: Long? = null, filterId: Long? = null,
dueToday: Boolean = false,
): String { ): String {
val params = buildList { val params = buildList {
if (presetListId != null) add("$ARG_PRESET_LIST_ID=$presetListId") if (presetListId != null) add("$ARG_PRESET_LIST_ID=$presetListId")
@@ -169,7 +165,6 @@ object Dest {
if (!title.isNullOrBlank()) add("$ARG_TITLE=${android.net.Uri.encode(title)}") if (!title.isNullOrBlank()) add("$ARG_TITLE=${android.net.Uri.encode(title)}")
if (!tag.isNullOrBlank()) add("$ARG_TAG=${android.net.Uri.encode(tag)}") if (!tag.isNullOrBlank()) add("$ARG_TAG=${android.net.Uri.encode(tag)}")
if (filterId != null) add("$ARG_FILTER_ID=$filterId") if (filterId != null) add("$ARG_FILTER_ID=$filterId")
if (dueToday) add("$ARG_DUE_TODAY=true")
} }
return if (params.isEmpty()) "taskEdit" else "taskEdit?${params.joinToString("&")}" return if (params.isEmpty()) "taskEdit" else "taskEdit?${params.joinToString("&")}"
} }
@@ -9,15 +9,8 @@ import de.jeanlucmakiola.agendula.domain.SmartList
* [AgendulaNavHost], which pushes it over whatever is showing. * [AgendulaNavHost], which pushes it over whatever is showing.
*/ */
sealed interface NavRequest { sealed interface NavRequest {
/** /** One task's detail; [occurrenceStart] (epoch millis) picks the occurrence of a series. */
* One task's detail; [occurrenceStart] (epoch millis) picks the occurrence of a series. data class OpenTask(val taskId: Long, val occurrenceStart: Long? = null) : NavRequest
* [returnHome] leaves the app once the detail is closed (a widget row, #93).
*/
data class OpenTask(
val taskId: Long,
val occurrenceStart: Long? = null,
val returnHome: Boolean = false,
) : NavRequest
/** Settings → Accounts, where a sync report's detail lives. */ /** Settings → Accounts, where a sync report's detail lives. */
data object OpenAccounts : NavRequest data object OpenAccounts : NavRequest
@@ -27,16 +20,9 @@ sealed interface NavRequest {
/** /**
* A new task, optionally titled with text shared from another app, or * 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 — * preset to a list — a widget's own add button, when it shows one list.
* or due today, from a Today widget.
* [returnHome] leaves the app once the form is closed (#93).
*/ */
data class NewTask( data class NewTask(val title: String? = null, val listId: Long? = null) : NavRequest
val title: String? = null,
val listId: Long? = null,
val dueToday: Boolean = false,
val returnHome: Boolean = false,
) : NavRequest
/** One of the smart lists — the launcher's "Today" shortcut, a widget header. */ /** One of the smart lists — the launcher's "Today" shortcut, a widget header. */
data class OpenSmart(val list: SmartList) : NavRequest data class OpenSmart(val list: SmartList) : NavRequest
@@ -12,23 +12,16 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.agendula.R import de.jeanlucmakiola.agendula.R
import de.jeanlucmakiola.agendula.data.appname.LauncherName
import de.jeanlucmakiola.agendula.data.prefs.ThemeMode import de.jeanlucmakiola.agendula.data.prefs.ThemeMode
import de.jeanlucmakiola.agendula.data.prefs.TimeFormatPref import de.jeanlucmakiola.agendula.data.prefs.TimeFormatPref
import de.jeanlucmakiola.agendula.data.prefs.is24Hour import de.jeanlucmakiola.agendula.data.prefs.is24Hour
import de.jeanlucmakiola.agendula.ui.common.formatMinuteOfDay import de.jeanlucmakiola.agendula.ui.common.formatMinuteOfDay
import de.jeanlucmakiola.agendula.ui.common.localeFirstDayOfWeek import de.jeanlucmakiola.agendula.ui.common.localeFirstDayOfWeek
import de.jeanlucmakiola.floret.components.CollapsingScaffold import de.jeanlucmakiola.floret.components.CollapsingScaffold
import de.jeanlucmakiola.floret.components.FullScreenPicker
import de.jeanlucmakiola.floret.components.GroupedRow import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.LauncherMark
import de.jeanlucmakiola.floret.components.LauncherNameChooser
import de.jeanlucmakiola.floret.components.OptionPicker import de.jeanlucmakiola.floret.components.OptionPicker
import de.jeanlucmakiola.floret.components.Position import de.jeanlucmakiola.floret.components.Position
import de.jeanlucmakiola.floret.locale.currentLocale import de.jeanlucmakiola.floret.locale.currentLocale
@@ -45,8 +38,6 @@ internal fun AppearanceScreen(
) { ) {
val dynamicColorAvailable = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S val dynamicColorAvailable = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
var showTheme by remember { mutableStateOf(false) } var showTheme by remember { mutableStateOf(false) }
var showAppName by remember { mutableStateOf(false) }
val launcherName by viewModel.launcherName.collectAsStateWithLifecycle()
var showTimeFormat by remember { mutableStateOf(false) } var showTimeFormat by remember { mutableStateOf(false) }
var showWeekStart by remember { mutableStateOf(false) } var showWeekStart by remember { mutableStateOf(false) }
val systemIs24Hour = DateFormat.is24HourFormat(LocalContext.current) val systemIs24Hour = DateFormat.is24HourFormat(LocalContext.current)
@@ -99,36 +90,6 @@ internal fun AppearanceScreen(
position = Position.Bottom, position = Position.Bottom,
onClick = { showWeekStart = true }, onClick = { showWeekStart = true },
) )
Spacer(Modifier.height(24.dp))
GroupedRow(
title = stringResource(R.string.settings_app_name),
summary = launcherNameLabel(launcherName),
position = Position.Alone,
onClick = { showAppName = true },
)
}
if (showAppName) {
FullScreenPicker(
title = stringResource(R.string.settings_app_name),
onDismiss = { showAppName = false },
predictiveBack = true,
) {
LauncherNameChooser(
summary = stringResource(R.string.settings_app_name_hint),
options = LauncherName.entries,
selected = launcherName,
label = { launcherNameLabel(it) },
onSelect = viewModel::setLauncherName,
mark = {
LauncherMark(
background = colorResource(R.color.ic_launcher_background),
foreground = painterResource(R.drawable.ic_launcher_foreground),
)
},
)
}
} }
if (showTimeFormat) { if (showTimeFormat) {
@@ -203,14 +164,6 @@ private fun weekStartLabel(day: DayOfWeek?): String =
/** 14:00, the sample time "Automatic" is shown with. */ /** 14:00, the sample time "Automatic" is shown with. */
private const val SAMPLE_MINUTE_OF_DAY = 14 * 60 private const val SAMPLE_MINUTE_OF_DAY = 14 * 60
@Composable
private fun launcherNameLabel(name: LauncherName): String = stringResource(
when (name) {
LauncherName.AGENDULA -> R.string.app_name
LauncherName.TASKS -> R.string.app_name_tasks_alias
},
)
private fun themeLabel(mode: ThemeMode): Int = when (mode) { private fun themeLabel(mode: ThemeMode): Int = when (mode) {
ThemeMode.SYSTEM -> R.string.settings_theme_system ThemeMode.SYSTEM -> R.string.settings_theme_system
ThemeMode.LIGHT -> R.string.settings_theme_light ThemeMode.LIGHT -> R.string.settings_theme_light
@@ -3,8 +3,6 @@ package de.jeanlucmakiola.agendula.ui.settings
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import de.jeanlucmakiola.agendula.data.appname.LauncherName
import de.jeanlucmakiola.agendula.data.appname.LauncherNameManager
import de.jeanlucmakiola.agendula.data.di.IoDispatcher import de.jeanlucmakiola.agendula.data.di.IoDispatcher
import de.jeanlucmakiola.agendula.data.prefs.Settings import de.jeanlucmakiola.agendula.data.prefs.Settings
import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs
@@ -35,7 +33,6 @@ import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.time.DayOfWeek import java.time.DayOfWeek
import javax.inject.Inject import javax.inject.Inject
@@ -105,8 +102,7 @@ class SettingsViewModel @Inject constructor(
private val oneShotImport: OneShotImport, private val oneShotImport: OneShotImport,
private val reminderScheduler: ReminderScheduler, private val reminderScheduler: ReminderScheduler,
private val taskNotifier: TaskNotifier, private val taskNotifier: TaskNotifier,
private val launcherNameManager: LauncherNameManager, @IoDispatcher io: CoroutineDispatcher,
@IoDispatcher private val io: CoroutineDispatcher,
repository: TasksRepository, repository: TasksRepository,
) : ViewModel() { ) : ViewModel() {
@@ -188,6 +184,7 @@ class SettingsViewModel @Inject constructor(
transferState.update { it.copy(confirming = false, running = true, outcome = null) } transferState.update { it.copy(confirming = false, running = true, outcome = null) }
viewModelScope.launch { viewModelScope.launch {
val result = externalImport.run() val result = externalImport.run()
if (result is TransferResult.Copied) runCatching { reminderScheduler.sync() }
transferState.update { transferState.update {
it.copy( it.copy(
running = false, running = false,
@@ -211,6 +208,7 @@ class SettingsViewModel @Inject constructor(
retryingLegacyImport.value = true retryingLegacyImport.value = true
viewModelScope.launch { viewModelScope.launch {
runCatching { oneShotImport.reimportFromArchive() } runCatching { oneShotImport.reimportFromArchive() }
runCatching { reminderScheduler.sync() }
retryingLegacyImport.value = false retryingLegacyImport.value = false
} }
} }
@@ -219,23 +217,6 @@ class SettingsViewModel @Inject constructor(
val legacyImportRetrying: StateFlow<Boolean> = retryingLegacyImport val legacyImportRetrying: StateFlow<Boolean> = retryingLegacyImport
private val _launcherName = MutableStateFlow(LauncherName.AGENDULA)
val launcherName: StateFlow<LauncherName> = _launcherName
init {
viewModelScope.launch { _launcherName.value = withContext(io) { launcherNameManager.current() } }
}
fun setLauncherName(name: LauncherName) {
_launcherName.value = name
viewModelScope.launch {
_launcherName.value = withContext(io) {
launcherNameManager.set(name)
launcherNameManager.current()
}
}
}
fun setThemeMode(mode: ThemeMode) = viewModelScope.launch { prefs.setThemeMode(mode) } fun setThemeMode(mode: ThemeMode) = viewModelScope.launch { prefs.setThemeMode(mode) }
fun setDynamicColor(enabled: Boolean) = viewModelScope.launch { prefs.setDynamicColor(enabled) } fun setDynamicColor(enabled: Boolean) = viewModelScope.launch { prefs.setDynamicColor(enabled) }
fun setDefaultList(id: Long?) = viewModelScope.launch { prefs.setDefaultListId(id) } fun setDefaultList(id: Long?) = viewModelScope.launch { prefs.setDefaultListId(id) }
@@ -112,8 +112,6 @@ import de.jeanlucmakiola.agendula.domain.TaskFilter
import de.jeanlucmakiola.agendula.domain.TaskSection import de.jeanlucmakiola.agendula.domain.TaskSection
import de.jeanlucmakiola.agendula.domain.TaskSections import de.jeanlucmakiola.agendula.domain.TaskSections
import de.jeanlucmakiola.agendula.ui.common.LocalUse24HourFormat import de.jeanlucmakiola.agendula.ui.common.LocalUse24HourFormat
import de.jeanlucmakiola.agendula.ui.common.SyncPullBox
import de.jeanlucmakiola.agendula.ui.common.ignoringEdgeSwipes
import de.jeanlucmakiola.agendula.ui.common.priorityAccent import de.jeanlucmakiola.agendula.ui.common.priorityAccent
import de.jeanlucmakiola.agendula.ui.lists.ListEditorSheet import de.jeanlucmakiola.agendula.ui.lists.ListEditorSheet
import de.jeanlucmakiola.agendula.ui.lists.listWriteFailureMessage import de.jeanlucmakiola.agendula.ui.lists.listWriteFailureMessage
@@ -146,8 +144,6 @@ fun TaskListScreen(
viewModel: TaskListViewModel = hiltViewModel(), viewModel: TaskListViewModel = hiltViewModel(),
) { ) {
val state by viewModel.state.collectAsStateWithLifecycle() val state by viewModel.state.collectAsStateWithLifecycle()
val syncAvailable by viewModel.syncAvailable.collectAsStateWithLifecycle()
val syncing by viewModel.syncing.collectAsStateWithLifecycle()
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior() val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior()
val content = state as? TaskListUiState.Content val content = state as? TaskListUiState.Content
val list = content?.list val list = content?.list
@@ -251,21 +247,13 @@ fun TaskListScreen(
}, },
) { inner -> ) { inner ->
Box(modifier = Modifier.fillMaxSize()) { Box(modifier = Modifier.fillMaxSize()) {
SyncPullBox( when (val s = state) {
available = syncAvailable, TaskListUiState.Loading -> Unit // brief; avoids a flash before first emission
syncing = syncing, TaskListUiState.Failure -> CenteredMessage(stringResource(R.string.task_list_failure), inner)
onSync = viewModel::syncAll, is TaskListUiState.Content -> TaskListBody(
modifier = Modifier.fillMaxSize(), s, filter, inner, onOpenTask, onRequestDelete, viewModel,
topPadding = inner.calculateTopPadding(), onClearCompleted = if (listId != null && list?.isReadOnly != true) ({ confirmClear = true }) else null,
) { )
when (val s = state) {
TaskListUiState.Loading -> Unit // brief; avoids a flash before first emission
TaskListUiState.Failure -> CenteredMessage(stringResource(R.string.task_list_failure), inner)
is TaskListUiState.Content -> TaskListBody(
s, filter, inner, onOpenTask, onRequestDelete, viewModel,
onClearCompleted = if (listId != null && list?.isReadOnly != true) ({ confirmClear = true }) else null,
)
}
} }
// A FAB-height band anchored at the bottom-start with the FAB's own // A FAB-height band anchored at the bottom-start with the FAB's own
// margin; centring the chip in it lines the chip up beside the bottom-end // margin; centring the chip in it lines the chip up beside the bottom-end
@@ -367,7 +355,7 @@ private fun TaskListBody(
viewModel: TaskListViewModel, viewModel: TaskListViewModel,
onClearCompleted: (() -> Unit)? = null, onClearCompleted: (() -> Unit)? = null,
) { ) {
val (todayStart, todayEnd) = remember(state.tasks) { DayWindow.today(Clock.System.now(), ZoneId.systemDefault()) } val (todayStart, todayEnd) = remember { DayWindow.today(Clock.System.now(), ZoneId.systemDefault()) }
// A parent represents its children via a progress chip (counts come from the // A parent represents its children via a progress chip (counts come from the
// repository, over the full set), so drop children from the flat list — unless // repository, over the full set), so drop children from the flat list — unless
// their parent isn't in this view (can happen in smart lists), where we keep // their parent isn't in this view (can happen in smart lists), where we keep
@@ -376,15 +364,15 @@ private fun TaskListBody(
val present = state.tasks.mapTo(HashSet()) { it.taskId } val present = state.tasks.mapTo(HashSet()) { it.taskId }
state.tasks.filter { (it.parentId ?: 0L) <= 0L || it.parentId !in present } state.tasks.filter { (it.parentId ?: 0L) <= 0L || it.parentId !in present }
} }
val sections = remember(displayTasks, todayStart) { val sections = remember(displayTasks) { TaskSections.of(displayTasks, todayStart, todayEnd) }
TaskSections.of(displayTasks, todayStart, todayEnd, todayList = filter == TaskFilter.Smart(SmartList.TODAY))
}
val showHeaders = sections.size > 1 val showHeaders = sections.size > 1
val listId = (filter as? TaskFilter.OfList)?.listId val listId = (filter as? TaskFilter.OfList)?.listId
// In a mixed view (a smart list) the list colour bar + name disambiguate // In a mixed view (a smart list) the list colour bar + name disambiguate
// rows; inside a single real list both are redundant. // rows; inside a single real list both are redundant.
val showListName = filter is TaskFilter.Smart || filter is TaskFilter.Tagged || val showListName = filter is TaskFilter.Smart || filter is TaskFilter.Tagged ||
(filter is TaskFilter.Saved && state.savedFilter?.listIds?.size != 1) (filter is TaskFilter.Saved && state.savedFilter?.listIds?.size != 1)
// Completed tasks collapse away by default to keep the active list in focus.
var completedExpanded by rememberSaveable { mutableStateOf(false) }
// Parents the user expanded to reveal their subtasks inline. Children are // Parents the user expanded to reveal their subtasks inline. Children are
// grouped from the same flat task set the list already holds. // grouped from the same flat task set the list already holds.
var expandedParents by rememberSaveable { mutableStateOf(emptySet<Long>()) } var expandedParents by rememberSaveable { mutableStateOf(emptySet<Long>()) }
@@ -432,20 +420,23 @@ private fun TaskListBody(
} }
sections.forEach { section -> sections.forEach { section ->
// Every header folds its section away (#91); Done starts folded. val collapsible = showHeaders && section.section == TaskSection.COMPLETED
val expanded = !showHeaders || section.section !in state.collapsedSections
if (showHeaders) { if (showHeaders) {
stickyHeader(key = "hdr-${section.section}") { stickyHeader(key = "hdr-${section.section}") {
CollapsibleSectionHeader( if (collapsible) {
label = sectionLabel(section.section), CollapsibleSectionHeader(
count = section.tasks.size, label = sectionLabel(section.section),
expanded = expanded, count = section.tasks.size,
onToggle = { viewModel.setSectionCollapsed(section.section, expanded) }, expanded = completedExpanded,
onClear = onClearCompleted.takeIf { section.section == TaskSection.COMPLETED }, onToggle = { completedExpanded = !completedExpanded },
) onClear = onClearCompleted,
)
} else {
SectionHeader(sectionLabel(section.section))
}
} }
} }
if (expanded) { if (!collapsible || completedExpanded) {
// Flatten the section into one grouped run: each parent, followed // Flatten the section into one grouped run: each parent, followed
// by its subtasks when expanded. Each row's corners then come from // by its subtasks when expanded. Each row's corners then come from
// its place in the whole run (positionOf below) — so the children // its place in the whole run (positionOf below) — so the children
@@ -664,7 +655,7 @@ private fun TaskRow(
SwipeToDismissBox( SwipeToDismissBox(
state = dismissState, state = dismissState,
modifier = modifier.ignoringEdgeSwipes().fillMaxWidth().padding(horizontal = 16.dp).then(gap), modifier = modifier.fillMaxWidth().padding(horizontal = 16.dp).then(gap),
// dismissDirection (not targetValue) tracks the live drag, so the colour + // dismissDirection (not targetValue) tracks the live drag, so the colour +
// icon reveal as you swipe rather than only once the threshold is crossed. // icon reveal as you swipe rather than only once the threshold is crossed.
backgroundContent = { SwipeBackground(dismissState.dismissDirection, task.isCompleted) }, backgroundContent = { SwipeBackground(dismissState.dismissDirection, task.isCompleted) },
@@ -1046,7 +1037,7 @@ private fun SubtaskRow(
val gap = if (lastInRun) Modifier else Modifier.padding(bottom = 2.dp) val gap = if (lastInRun) Modifier else Modifier.padding(bottom = 2.dp)
SwipeToDismissBox( SwipeToDismissBox(
state = dismissState, state = dismissState,
modifier = modifier.ignoringEdgeSwipes().fillMaxWidth().padding(horizontal = 16.dp).then(gap), modifier = modifier.fillMaxWidth().padding(horizontal = 16.dp).then(gap),
backgroundContent = { SwipeBackground(dismissState.dismissDirection, task.isCompleted) }, backgroundContent = { SwipeBackground(dismissState.dismissDirection, task.isCompleted) },
) { ) {
Surface( Surface(
@@ -1136,6 +1127,19 @@ private fun cardShape(position: Position, full: androidx.compose.ui.unit.Dp, sma
Position.Bottom -> RoundedCornerShape(topStart = small, topEnd = small, bottomStart = full, bottomEnd = full) Position.Bottom -> RoundedCornerShape(topStart = small, topEnd = small, bottomStart = full, bottomEnd = full)
} }
@Composable
private fun SectionHeader(text: String) {
// Opaque surface so rows scroll cleanly under the pinned header.
Surface(color = MaterialTheme.colorScheme.surface, modifier = Modifier.fillMaxWidth()) {
Text(
text = text,
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 28.dp, end = 28.dp, top = 16.dp, bottom = 8.dp),
)
}
}
/** A tappable section header with a count and a chevron that rotates on expand. */ /** A tappable section header with a count and a chevron that rotates on expand. */
@Composable @Composable
private fun CollapsibleSectionHeader( private fun CollapsibleSectionHeader(
@@ -3,7 +3,6 @@ package de.jeanlucmakiola.agendula.ui.tasklist
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import de.jeanlucmakiola.agendula.data.prefs.DEFAULT_COLLAPSED_SECTIONS
import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.agendula.data.sync.RemoteLists import de.jeanlucmakiola.agendula.data.sync.RemoteLists
import de.jeanlucmakiola.agendula.data.tasks.TasksRepository import de.jeanlucmakiola.agendula.data.tasks.TasksRepository
@@ -15,13 +14,11 @@ import de.jeanlucmakiola.agendula.domain.TaskFiltering
import de.jeanlucmakiola.agendula.domain.tagsInUse import de.jeanlucmakiola.agendula.domain.tagsInUse
import de.jeanlucmakiola.agendula.domain.TaskForm import de.jeanlucmakiola.agendula.domain.TaskForm
import de.jeanlucmakiola.agendula.domain.TaskList import de.jeanlucmakiola.agendula.domain.TaskList
import de.jeanlucmakiola.agendula.domain.TaskSection
import de.jeanlucmakiola.agendula.domain.TaskSortOrder import de.jeanlucmakiola.agendula.domain.TaskSortOrder
import de.jeanlucmakiola.agendula.domain.comparator import de.jeanlucmakiola.agendula.domain.comparator
import de.jeanlucmakiola.agendula.ui.lists.ListWriteFailure import de.jeanlucmakiola.agendula.ui.lists.ListWriteFailure
import de.jeanlucmakiola.agendula.ui.lists.asFailure import de.jeanlucmakiola.agendula.ui.lists.asFailure
import de.jeanlucmakiola.agendula.data.di.ApplicationScope import de.jeanlucmakiola.agendula.data.di.ApplicationScope
import de.jeanlucmakiola.agendula.data.sync.ManualSync
import de.jeanlucmakiola.agendula.domain.recurrence.RecurringScope import de.jeanlucmakiola.agendula.domain.recurrence.RecurringScope
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
@@ -59,8 +56,6 @@ sealed interface TaskListUiState {
/** Whether a real list uses the bottom quick-add bar instead of the FAB. */ /** Whether a real list uses the bottom quick-add bar instead of the FAB. */
val bottomAddBar: Boolean = false, val bottomAddBar: Boolean = false,
val sortOrder: TaskSortOrder = TaskSortOrder.DUE, val sortOrder: TaskSortOrder = TaskSortOrder.DUE,
/** Sections whose rows are folded under their header. */
val collapsedSections: Set<TaskSection> = DEFAULT_COLLAPSED_SECTIONS,
/** This real list is kept out of the smart lists. */ /** This real list is kept out of the smart lists. */
val hiddenFromSmartLists: Boolean = false, val hiddenFromSmartLists: Boolean = false,
/** Tags the unnarrowed view carries, offered as filter chips. */ /** Tags the unnarrowed view carries, offered as filter chips. */
@@ -94,17 +89,9 @@ class TaskListViewModel @Inject constructor(
private val repository: TasksRepository, private val repository: TasksRepository,
private val settingsPrefs: SettingsPrefs, private val settingsPrefs: SettingsPrefs,
private val remoteLists: RemoteLists, private val remoteLists: RemoteLists,
private val manualSync: ManualSync,
@ApplicationScope private val appScope: CoroutineScope, @ApplicationScope private val appScope: CoroutineScope,
) : ViewModel() { ) : ViewModel() {
/** Pull to sync (#73); off without an account. */
val syncAvailable: StateFlow<Boolean> =
manualSync.available.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false)
val syncing: StateFlow<Boolean> = manualSync.syncing
fun syncAll() = manualSync.syncAll()
private val filter = MutableStateFlow<TaskFilter?>(null) private val filter = MutableStateFlow<TaskFilter?>(null)
private val tagFilter = MutableStateFlow<Set<String>>(emptySet()) private val tagFilter = MutableStateFlow<Set<String>>(emptySet())
@@ -157,7 +144,6 @@ class TaskListViewModel @Inject constructor(
showAddSubtaskRow = settings.showAddSubtaskRow, showAddSubtaskRow = settings.showAddSubtaskRow,
bottomAddBar = settings.bottomAddBar, bottomAddBar = settings.bottomAddBar,
sortOrder = settings.sortOrder, sortOrder = settings.sortOrder,
collapsedSections = settings.collapsedSections,
hiddenFromSmartLists = st.list?.id?.let { it in settings.hiddenFromSmartLists } ?: false, hiddenFromSmartLists = st.list?.id?.let { it in settings.hiddenFromSmartLists } ?: false,
) )
} else { } else {
@@ -291,9 +277,6 @@ class TaskListViewModel @Inject constructor(
fun setHiddenFromSmartLists(listId: Long, hidden: Boolean) = fun setHiddenFromSmartLists(listId: Long, hidden: Boolean) =
viewModelScope.launch { settingsPrefs.setHiddenFromSmartLists(listId, hidden) } viewModelScope.launch { settingsPrefs.setHiddenFromSmartLists(listId, hidden) }
fun setSectionCollapsed(section: TaskSection, collapsed: Boolean) =
viewModelScope.launch { settingsPrefs.setSectionCollapsed(section, collapsed) }
fun setSortOrder(order: TaskSortOrder) = viewModelScope.launch { settingsPrefs.setSortOrder(order) } fun setSortOrder(order: TaskSortOrder) = viewModelScope.launch { settingsPrefs.setSortOrder(order) }
/** /**
@@ -1,44 +0,0 @@
package de.jeanlucmakiola.agendula.ui.theme
import android.app.Activity
import android.app.UiModeManager
import android.os.Build
import androidx.annotation.StyleRes
import de.jeanlucmakiola.agendula.R
import de.jeanlucmakiola.agendula.data.prefs.ThemeMode
/**
* Hands the app's theme setting to the system, which draws the launch window
* and splash before any of the app runs (#119). The system keeps both across
* launches, so the next start already matches.
*/
object SystemTheme {
/** The light or dark the app's resources follow, as [UiModeManager.setApplicationNightMode] takes it. */
fun nightModeOf(mode: ThemeMode): Int = when (mode) {
ThemeMode.SYSTEM -> UiModeManager.MODE_NIGHT_AUTO
ThemeMode.LIGHT -> UiModeManager.MODE_NIGHT_NO
ThemeMode.DARK, ThemeMode.BLACK -> UiModeManager.MODE_NIGHT_YES
}
/** The splash theme whose background is the one the app opens on. */
@StyleRes
fun splashThemeOf(mode: ThemeMode, dynamicColor: Boolean): Int = when {
mode.isPureBlack -> R.style.Theme_Agendula_Black
dynamicColor -> R.style.Theme_Agendula
else -> R.style.Theme_Agendula_Fallback
}
/**
* Applies [mode] and [dynamicColor]. A night mode the activity does not
* have yet recreates it, once; the same one again changes nothing.
*/
fun apply(activity: Activity, mode: ThemeMode, dynamicColor: Boolean) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
activity.getSystemService(UiModeManager::class.java)?.setApplicationNightMode(nightModeOf(mode))
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
activity.splashScreen.setSplashScreenTheme(splashThemeOf(mode, dynamicColor))
}
}
}
@@ -194,12 +194,10 @@ private fun TaskWidgetBody(
Spacer(GlanceModifier.height(R.dimen.widget_tasks_header_gap_tight)) Spacer(GlanceModifier.height(R.dimen.widget_tasks_header_gap_tight))
WidgetDoneState(context.getString(R.string.widget_empty), context.getString(R.string.widget_empty_hint)) WidgetDoneState(context.getString(R.string.widget_empty), context.getString(R.string.widget_empty_hint))
} else { } else {
val todayList = filter == TaskFilter.Smart(SmartList.TODAY)
val allSections = widgetSections( val allSections = widgetSections(
data.rows, data.rows,
data.groupByList, data.groupByList,
data.listNames, data.listNames,
todayList,
) { kind -> context.getString(dueBucketLabel(kind)) } ) { kind -> context.getString(dueBucketLabel(kind)) }
// A lone group whose bucket repeats the widget's own smart list // A lone group whose bucket repeats the widget's own smart list
// (the "Today" smart list showing one "Today" bucket) earns no // (the "Today" smart list showing one "Today" bucket) earns no
@@ -250,8 +248,7 @@ private fun TaskWidgetBody(
// (Today / Tomorrow) whenever one is shown — and when // (Today / Tomorrow) whenever one is shown — and when
// it isn't, that's only because it would have repeated // it isn't, that's only because it would have repeated
// the title, which names the same day just as well. // the title, which names the same day just as well.
suppressDayLabel = !data.groupByList && suppressDayLabel = !data.groupByList,
(!todayList || section.row.dueKind == DueKind.TODAY),
) )
Spacer(GlanceModifier.height(8.dp)) Spacer(GlanceModifier.height(8.dp))
} }
@@ -307,9 +304,7 @@ private fun TaskWidgetHeader(title: String, filter: TaskFilter, singleListId: Lo
WidgetHeaderAction( WidgetHeaderAction(
icon = ImageProvider(GlanceR.drawable.floret_ic_widget_add), icon = ImageProvider(GlanceR.drawable.floret_ic_widget_add),
contentDescription = context.getString(R.string.new_task), contentDescription = context.getString(R.string.new_task),
action = actionStartActivity( action = actionStartActivity(MainActivity.newTaskIntent(context, singleListId)),
MainActivity.newTaskIntent(context, singleListId, dueToday = filter == TaskFilter.Smart(SmartList.TODAY)),
),
) )
} }
} }
@@ -333,7 +328,7 @@ internal fun AgendaTaskRow(
val context = LocalContext.current val context = LocalContext.current
val title = row.title.ifBlank { context.getString(R.string.task_untitled) } val title = row.title.ifBlank { context.getString(R.string.task_untitled) }
val open = actionStartActivity( val open = actionStartActivity(
MainActivity.taskIntent(context, row.taskId, row.occurrenceStart?.toEpochMilliseconds(), returnHome = true), MainActivity.taskIntent(context, row.taskId, row.occurrenceStart?.toEpochMilliseconds()),
) )
val onColor = if (colored) GlanceColorProvider(Color.White) else GlanceTheme.colors.onSurface val onColor = if (colored) GlanceColorProvider(Color.White) else GlanceTheme.colors.onSurface
val onColorVariant = if (colored) GlanceColorProvider(Color.White.copy(alpha = 0.8f)) else GlanceTheme.colors.onSurfaceVariant val onColorVariant = if (colored) GlanceColorProvider(Color.White.copy(alpha = 0.8f)) else GlanceTheme.colors.onSurfaceVariant
@@ -3,11 +3,14 @@ package de.jeanlucmakiola.agendula.widget
import android.content.Context import android.content.Context
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import de.jeanlucmakiola.agendula.data.di.ApplicationScope import de.jeanlucmakiola.agendula.data.di.ApplicationScope
import de.jeanlucmakiola.agendula.data.tasks.StoreChangeDebouncer
import de.jeanlucmakiola.agendula.data.tasks.TasksDataSource import de.jeanlucmakiola.agendula.data.tasks.TasksDataSource
import de.jeanlucmakiola.floret.glance.WidgetRollover import de.jeanlucmakiola.floret.glance.WidgetRollover
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@@ -23,10 +26,16 @@ class TaskWidgetUpdater @Inject constructor(
private val dataSource: TasksDataSource, private val dataSource: TasksDataSource,
@ApplicationScope private val scope: CoroutineScope, @ApplicationScope private val scope: CoroutineScope,
) { ) {
private val changes = StoreChangeDebouncer(scope, DEBOUNCE_MILLIS, dataSource::registerObserver, ::redraw) private val requests = Channel<Unit>(Channel.CONFLATED)
private var observer: AutoCloseable? = null
@OptIn(FlowPreview::class)
fun start() { fun start() {
if (!changes.start()) return if (observer != null) return
observer = runCatching { dataSource.registerObserver { requests.trySend(Unit) } }.getOrNull()
scope.launch(Dispatchers.IO) {
requests.receiveAsFlow().debounce(DEBOUNCE_MILLIS).collect { redraw() }
}
scope.launch(Dispatchers.IO) { scope.launch(Dispatchers.IO) {
runCatching { WidgetRollover.sync(context, WIDGET_RECEIVER_CLASSES, ::widgetRolloverPendingIntent) } runCatching { WidgetRollover.sync(context, WIDGET_RECEIVER_CLASSES, ::widgetRolloverPendingIntent) }
} }
@@ -35,7 +44,9 @@ class TaskWidgetUpdater @Inject constructor(
} }
/** Redraw soon; bursts collapse into one pass. */ /** Redraw soon; bursts collapse into one pass. */
fun requestRefresh() = changes.request() fun requestRefresh() {
requests.trySend(Unit)
}
private suspend fun redraw() { private suspend fun redraw() {
runCatching { runCatching {
@@ -100,7 +100,7 @@ private fun TodayWidgetBody(data: WidgetStatsData?, background: WidgetBackground
WidgetHeaderAction( WidgetHeaderAction(
icon = ImageProvider(GlanceR.drawable.floret_ic_widget_add), icon = ImageProvider(GlanceR.drawable.floret_ic_widget_add),
contentDescription = context.getString(R.string.new_task), contentDescription = context.getString(R.string.new_task),
action = actionStartActivity(MainActivity.newTaskIntent(context, dueToday = true)), action = actionStartActivity(MainActivity.newTaskIntent(context)),
) )
} }
Spacer(GlanceModifier.height(HeaderGap)) Spacer(GlanceModifier.height(HeaderGap))
@@ -339,7 +339,7 @@ private fun ListDot(color: Int) {
} }
private fun openTask(context: Context, row: WidgetTaskRow): Action = private fun openTask(context: Context, row: WidgetTaskRow): Action =
actionStartActivity(MainActivity.taskIntent(context, row.taskId, row.occurrenceStart?.toEpochMilliseconds(), returnHome = true)) actionStartActivity(MainActivity.taskIntent(context, row.taskId, row.occurrenceStart?.toEpochMilliseconds()))
private fun completeTask(row: WidgetTaskRow): Action = private fun completeTask(row: WidgetTaskRow): Action =
actionRunCallback<CompleteTaskAction>( actionRunCallback<CompleteTaskAction>(
@@ -200,14 +200,12 @@ private fun headedRuns(runs: List<WidgetRun>): List<WidgetSection> =
* (already sorted that way by [widgetRows]), otherwise one per [DueKind] * (already sorted that way by [widgetRows]), otherwise one per [DueKind]
* bucket — "Overdue" / "Today" / "Tomorrow" / "Later" / "No date" — in that * bucket — "Overdue" / "Today" / "Tomorrow" / "Later" / "No date" — in that
* fixed order, since [rows] isn't due-sorted. Each run becomes one header * fixed order, since [rows] isn't due-sorted. Each run becomes one header
* followed by its own standalone, independently coloured task pills. The Today * followed by its own standalone, independently coloured task pills.
* list ([todayList]) is one "Today" bucket, as a row may be there by its start.
*/ */
internal fun widgetSections( internal fun widgetSections(
rows: List<WidgetTaskRow>, rows: List<WidgetTaskRow>,
groupByList: Boolean, groupByList: Boolean,
listNames: Map<Long, String>, listNames: Map<Long, String>,
todayList: Boolean = false,
dueBucketLabel: (DueKind?) -> String, dueBucketLabel: (DueKind?) -> String,
): List<WidgetSection> { ): List<WidgetSection> {
if (groupByList) { if (groupByList) {
@@ -222,7 +220,7 @@ internal fun widgetSections(
} }
return headedRuns(runs) return headedRuns(runs)
} }
val buckets = rows.groupBy { if (todayList) DueKind.TODAY else it.dueKind } val buckets = rows.groupBy { it.dueKind }
val runs = DueKind.entries.plus(null).mapNotNull { kind -> val runs = DueKind.entries.plus(null).mapNotNull { kind ->
buckets[kind]?.takeIf { it.isNotEmpty() } buckets[kind]?.takeIf { it.isNotEmpty() }
?.let { WidgetRun("due:${kind?.name.orEmpty()}", dueBucketLabel(kind), it) } ?.let { WidgetRun("due:${kind?.name.orEmpty()}", dueBucketLabel(kind), it) }
@@ -7,11 +7,9 @@ import de.jeanlucmakiola.agendula.data.prefs.is24Hour
import de.jeanlucmakiola.agendula.domain.SmartList import de.jeanlucmakiola.agendula.domain.SmartList
import de.jeanlucmakiola.agendula.domain.Task import de.jeanlucmakiola.agendula.domain.Task
import de.jeanlucmakiola.agendula.domain.TaskFilter import de.jeanlucmakiola.agendula.domain.TaskFilter
import de.jeanlucmakiola.agendula.domain.TaskFiltering
import de.jeanlucmakiola.agendula.domain.TaskList import de.jeanlucmakiola.agendula.domain.TaskList
import de.jeanlucmakiola.agendula.domain.calendarDate import de.jeanlucmakiola.agendula.domain.calendarDate
import de.jeanlucmakiola.agendula.domain.comparator import de.jeanlucmakiola.agendula.domain.comparator
import de.jeanlucmakiola.floret.time.DayWindow
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
@@ -47,15 +45,10 @@ internal sealed interface WidgetStatsData {
private const val MAX_TODAY_ROWS = 20 private const val MAX_TODAY_ROWS = 20
/** Whether this open task is on the Today widget's plate: in the app's Today, or overdue. */
private fun Task.isDueToday(now: Instant, today: LocalDate, todayStart: Instant, todayEnd: Instant, zone: ZoneId): Boolean =
TaskFiltering.isToday(this, todayStart, todayEnd, zone) ||
due?.let { isOverdue(it, isAllDay, now, today, zone) } == true
/** /**
* How far through today a set of [open] and [completed] tasks says you are: * How far through today a set of [open] and [completed] tasks says you are:
* the app's Today tasks done and still open ([TaskFiltering.isToday]) plus the * tasks done today (by [Task.completedAt]'s local date), tasks still open that
* overdue ones, the open ones in [comparator] order, and those tasks as rows. * are overdue or due today (in [comparator] order), and those tasks as rows.
*/ */
internal fun todayProgress( internal fun todayProgress(
open: List<Task>, open: List<Task>,
@@ -65,9 +58,14 @@ internal fun todayProgress(
today: LocalDate, today: LocalDate,
zone: ZoneId, zone: ZoneId,
): Triple<Int, Int, List<WidgetTaskRow>> { ): Triple<Int, Int, List<WidgetTaskRow>> {
val (todayStart, todayEnd) = DayWindow.today(now, zone) val done = completed.count { task ->
val done = completed.count { TaskFiltering.isToday(it, todayStart, todayEnd, zone) } val at = task.completedAt ?: return@count false
val remaining = open.filter { it.isDueToday(now, today, todayStart, todayEnd, zone) }.sortedWith(comparator) at.toLocalDate(zone) == today
}
val remaining = open.filter { task ->
val due = task.due ?: return@filter false
isOverdue(due, task.isAllDay, now, today, zone) || dueKind(due, task.isAllDay, today, zone) == DueKind.TODAY
}.sortedWith(comparator)
return Triple(done, remaining.size, remaining.take(MAX_TODAY_ROWS).map { it.toWidgetRow(now, today, zone) }) return Triple(done, remaining.size, remaining.take(MAX_TODAY_ROWS).map { it.toWidgetRow(now, today, zone) })
} }
@@ -78,29 +76,29 @@ internal fun upcoming(open: List<Task>, now: Instant, today: LocalDate, zone: Zo
private const val MAX_UPCOMING_ROWS = 200 private const val MAX_UPCOMING_ROWS = 200
/** /**
* Seven [WeekDay]s starting at [today]. Today's bucket holds what the Today * Seven [WeekDay]s starting at [today]. Today's bucket folds in every overdue
* widget counts — the app's Today tasks plus every overdue one; every other day * task regardless of its own due day, since that's where the app's own
* holds the rest by their own due date. * Overdue/Today smart lists put it; every other day matches its own due date.
*/ */
internal fun weekDays(open: List<Task>, comparator: Comparator<Task>, now: Instant, today: LocalDate, zone: ZoneId): List<WeekDay> { internal fun weekDays(open: List<Task>, comparator: Comparator<Task>, now: Instant, today: LocalDate, zone: ZoneId): List<WeekDay> =
val (todayStart, todayEnd) = DayWindow.today(now, zone) (0..6L).map { offset ->
val (todays, later) = open.partition { it.isDueToday(now, today, todayStart, todayEnd, zone) }
return (0..6L).map { offset ->
val date = today.plusDays(offset) val date = today.plusDays(offset)
val dayTasks = if (offset == 0L) { val dayTasks = open.filter { task ->
todays val due = task.due ?: return@filter false
} else { if (offset == 0L) {
later.filter { task -> task.due?.calendarDate(task.isAllDay, zone) == date } isOverdue(due, task.isAllDay, now, today, zone) || due.calendarDate(task.isAllDay, zone) == date
} else {
due.calendarDate(task.isAllDay, zone) == date
}
} }
val overdueCount = if (offset == 0L) { val overdueCount = if (offset == 0L) {
dayTasks.count { task -> task.due?.let { isOverdue(it, task.isAllDay, now, today, zone) } == true } dayTasks.count { isOverdue(it.due!!, it.isAllDay, now, today, zone) }
} else { } else {
0 0
} }
val first = dayTasks.sortedWith(comparator).firstOrNull()?.toWidgetRow(now, today, zone) val first = dayTasks.sortedWith(comparator).firstOrNull()?.toWidgetRow(now, today, zone)
WeekDay(date, dayTasks.size, overdueCount, first) WeekDay(date, dayTasks.size, overdueCount, first)
} }
}
/** One tile per visible list: its open, non-subtask count — a subtask whose parent is also open is left out. */ /** One tile per visible list: its open, non-subtask count — a subtask whose parent is also open is left out. */
internal fun listTiles(open: List<Task>, lists: List<TaskList>): List<ListTile> { internal fun listTiles(open: List<Task>, lists: List<TaskList>): List<ListTile> {
+11 -113
View File
@@ -55,8 +55,8 @@
<string name="settings_theme">السمة</string> <string name="settings_theme">السمة</string>
<string name="settings_theme_light">فاتح</string> <string name="settings_theme_light">فاتح</string>
<string name="settings_theme_dark">داكن</string> <string name="settings_theme_dark">داكن</string>
<string name="settings_section_reminders">الإشعارات</string> <string name="settings_section_reminders">التذكيرات</string>
<string name="settings_list_notifications">الصوت والاهتزاز</string> <string name="settings_list_notifications">الإشعارات</string>
<string name="settings_group_data">البيانات</string> <string name="settings_group_data">البيانات</string>
<string name="settings_group_app">التطبيق</string> <string name="settings_group_app">التطبيق</string>
<string name="settings_group_about">حول</string> <string name="settings_group_about">حول</string>
@@ -65,7 +65,7 @@
<string name="settings_section_storage">التخزين</string> <string name="settings_section_storage">التخزين</string>
<string name="settings_section_accounts">الحسابات</string> <string name="settings_section_accounts">الحسابات</string>
<string name="settings_list_reminder_inherits">الافتراضي (%1$s)</string> <string name="settings_list_reminder_inherits">الافتراضي (%1$s)</string>
<string name="settings_section_task_form">نموذج مهمة جديدة</string> <string name="settings_section_task_form">نموذج مهمة</string>
<string name="settings_about_support">دَعم التطوير</string> <string name="settings_about_support">دَعم التطوير</string>
<string name="settings_about_version">الإصدار %1$s</string> <string name="settings_about_version">الإصدار %1$s</string>
<string name="settings_language">لغة التطبيق</string> <string name="settings_language">لغة التطبيق</string>
@@ -73,7 +73,7 @@
<string name="settings_translate">المساعدة في الترجمة</string> <string name="settings_translate">المساعدة في الترجمة</string>
<string name="settings_theme_system">اتباع النظام</string> <string name="settings_theme_system">اتباع النظام</string>
<string name="settings_dynamic_color">اللون الديناميكي</string> <string name="settings_dynamic_color">اللون الديناميكي</string>
<string name="settings_reminders">تذكيرات المهام</string> <string name="settings_reminders">التذكيرات المستحقة</string>
<string name="settings_exact_alarms">التوقيت الدقيق</string> <string name="settings_exact_alarms">التوقيت الدقيق</string>
<string name="settings_default_list">القائمة الافتراضية</string> <string name="settings_default_list">القائمة الافتراضية</string>
<string name="settings_time_format">تنسيق الوقت</string> <string name="settings_time_format">تنسيق الوقت</string>
@@ -90,7 +90,7 @@
<string name="settings_about_author">بواسطة Jean-Luc Makiola</string> <string name="settings_about_author">بواسطة Jean-Luc Makiola</string>
<string name="settings_about_logo_desc">رمز تطبيق Agendula</string> <string name="settings_about_logo_desc">رمز تطبيق Agendula</string>
<string name="settings_report_problem">الإبلاغ عن مشكلة</string> <string name="settings_report_problem">الإبلاغ عن مشكلة</string>
<string name="settings_default_reminder">التذكير الافتراضي</string> <string name="settings_default_reminder">متى لتذكير</string>
<string name="settings_group_look">المظهر و السلوك</string> <string name="settings_group_look">المظهر و السلوك</string>
<string name="settings_bottom_add_bar">شريط الإضافة السريع السفلي</string> <string name="settings_bottom_add_bar">شريط الإضافة السريع السفلي</string>
<string name="settings_time_format_12h">١٢-ساعة (٢:٠٠ م)</string> <string name="settings_time_format_12h">١٢-ساعة (٢:٠٠ م)</string>
@@ -168,7 +168,7 @@
<string name="reminder_unit_days">أيام</string> <string name="reminder_unit_days">أيام</string>
<string name="reminder_unit_weeks">أسابيع</string> <string name="reminder_unit_weeks">أسابيع</string>
<string name="settings_list_reminders_title">التذكيرات لكل قائمة</string> <string name="settings_list_reminders_title">التذكيرات لكل قائمة</string>
<string name="settings_list_reminders_hint">تجاوز الافتراضي لكل قائمة — بشكل منفصل للمحدد بوقت ومهام طوال اليوم. يمكن لقائمة الاحتفاظ بالافتراضي، إسقاطه أو تعيين خاصّته.</string> <string name="settings_list_reminders_hint">تجاوز التذكير الافتراضي للقوائم الفردية</string>
<string name="lists_reorder_handle">اسحب لإعادة الترتيب</string> <string name="lists_reorder_handle">اسحب لإعادة الترتيب</string>
<string name="settings_list_notifications_hint">الصوت، الاهتزاز و الأهمية لتذكيرات هذه القائمة</string> <string name="settings_list_notifications_hint">الصوت، الاهتزاز و الأهمية لتذكيرات هذه القائمة</string>
<string name="smart_today">اليوم</string> <string name="smart_today">اليوم</string>
@@ -446,8 +446,8 @@
<string name="filter_missing">تم حذف عامل التصفية</string> <string name="filter_missing">تم حذف عامل التصفية</string>
<string name="view_filter_tags">تصفية حسب الوسم</string> <string name="view_filter_tags">تصفية حسب الوسم</string>
<string name="filter_section_lists">القوائم</string> <string name="filter_section_lists">القوائم</string>
<string name="settings_appearance_subtitle">السمة، الألوان، تنسيق الوقت</string> <string name="settings_appearance_subtitle">السمة، اللون و تنسيق الوقت</string>
<string name="settings_task_form_subtitle">الخانات الافتراضية و السلوك</string> <string name="settings_task_form_subtitle">الحقول، القائمة والمهام الفرعية الافتراضية</string>
<string name="recurrence_on_days">%1$s في %2$s</string> <string name="recurrence_on_days">%1$s في %2$s</string>
<string name="recurrence_with_until">%1$s حتى %2$s</string> <string name="recurrence_with_until">%1$s حتى %2$s</string>
<plurals name="recurrence_with_count"> <plurals name="recurrence_with_count">
@@ -521,7 +521,7 @@
<string name="add_account_server_title">أين مهامك؟</string> <string name="add_account_server_title">أين مهامك؟</string>
<string name="add_account_working_reading_lists">جاري قراءة قوائم المهام خاصّتك</string> <string name="add_account_working_reading_lists">جاري قراءة قوائم المهام خاصّتك</string>
<string name="add_account_browser_title">جاري الانتظار لمتصفحك</string> <string name="add_account_browser_title">جاري الانتظار لمتصفحك</string>
<string name="add_account_browser_use_password">استخدم كلمة مرور بدلاً من ذلك</string> <string name="add_account_browser_use_password">استخدم كلمة مرور بدلاً</string>
<string name="add_account_browser_open">فتح صفحة تسجيل الدخول</string> <string name="add_account_browser_open">فتح صفحة تسجيل الدخول</string>
<string name="add_account_lists_title">أيّ القوائم يجب المزامنة؟</string> <string name="add_account_lists_title">أيّ القوائم يجب المزامنة؟</string>
<string name="add_account_provider_unsupported">لا دعم للمهام عبر CalDAV</string> <string name="add_account_provider_unsupported">لا دعم للمهام عبر CalDAV</string>
@@ -569,7 +569,7 @@
<string name="add_account_browser_error_expired">تم إغلاق نافذة الموافقة قبل أن يرد الخادم.</string> <string name="add_account_browser_error_expired">تم إغلاق نافذة الموافقة قبل أن يرد الخادم.</string>
<string name="add_account_browser_error_rate_limited">يرفض الخادم المحاولات المتكررة. انتظر بضع دقائق وحاول مرة أخرى.</string> <string name="add_account_browser_error_rate_limited">يرفض الخادم المحاولات المتكررة. انتظر بضع دقائق وحاول مرة أخرى.</string>
<string name="add_account_browser_error_maintenance">الخادم في وضع الصيانة. حاول مرة أخرى بمجرد عودته.</string> <string name="add_account_browser_error_maintenance">الخادم في وضع الصيانة. حاول مرة أخرى بمجرد عودته.</string>
<string name="add_account_browser_error_unavailable">لا يحتوي هذا الجهاز على متصفح يمكنه فتح صفحة تسجيل الدخول. استخدم كلمة مرور بدلاً من ذلك.</string> <string name="add_account_browser_error_unavailable">لا يحتوي هذا الجهاز على متصفح يمكنه فتح صفحة تسجيل الدخول. استخدم كلمة مرور بدلاً.</string>
<string name="add_account_quirk_hint_fastmail">يحتاج Fastmail كلمة مرور تطبيق، ليس كلمة مرور حسابك — وCalDAV ليس على الخطة الأساسية.</string> <string name="add_account_quirk_hint_fastmail">يحتاج Fastmail كلمة مرور تطبيق، ليس كلمة مرور حسابك — وCalDAV ليس على الخطة الأساسية.</string>
<string name="add_account_quirk_hint_icloud">يحتاج iCloud كلمة مرور خاصة بتطبيق، التي يمكنك إنشاؤها في appleid.apple.com مع تفعيل المصادقة الثنائية.</string> <string name="add_account_quirk_hint_icloud">يحتاج iCloud كلمة مرور خاصة بتطبيق، التي يمكنك إنشاؤها في appleid.apple.com مع تفعيل المصادقة الثنائية.</string>
<string name="add_account_setup_body">ستقوم بذلك في متصفحك، ثم العودة هنا لإكمال.</string> <string name="add_account_setup_body">ستقوم بذلك في متصفحك، ثم العودة هنا لإكمال.</string>
@@ -580,7 +580,7 @@
<string name="add_account_browser_body">وافق على Agendula في المتصفح، ثم ارجع هنا. لا يتم إرسال كلمة مرورك مطلقًا إلى هذا التطبيق — يصدر الخادم كلمة مرور تطبيق منفصلة يمكنك إلغاؤها في أي وقت.</string> <string name="add_account_browser_body">وافق على Agendula في المتصفح، ثم ارجع هنا. لا يتم إرسال كلمة مرورك مطلقًا إلى هذا التطبيق — يصدر الخادم كلمة مرور تطبيق منفصلة يمكنك إلغاؤها في أي وقت.</string>
<string name="add_account_browser_insecure">صفحة تسجيل الدخول هذه غير مشفرة (http). أي شيء تكتبه هناك، بما في ذلك كلمة مرور حسابك، ينتقل بشكل غير مشفر.</string> <string name="add_account_browser_insecure">صفحة تسجيل الدخول هذه غير مشفرة (http). أي شيء تكتبه هناك، بما في ذلك كلمة مرور حسابك، ينتقل بشكل غير مشفر.</string>
<string name="add_account_browser_host_mismatch">الخادم أرسلنا إلى %1$s، لكنك كتبت %2$s. يعني ذلك عادةً أن إعداد overwrite.cli.url خاصّته غير صحيح.</string> <string name="add_account_browser_host_mismatch">الخادم أرسلنا إلى %1$s، لكنك كتبت %2$s. يعني ذلك عادةً أن إعداد overwrite.cli.url خاصّته غير صحيح.</string>
<string name="add_account_origin_mismatch">أبلغ الخادم عن عنوانه كـ %1$s، الذي لا يستطيع هذا الجهاز الوصول، لذلك تم استخدام %2$s بدلاً من ذلك. من المحتمل أن يكون إعداد overwrite.cli.url أو trusted_proxies خاصّته غير صحيح.</string> <string name="add_account_origin_mismatch">أبلغ الخادم عن عنوانه كـ %1$s، الذي لا يستطيع هذا الجهاز الوصول، لذلك تم استخدام %2$s بدلاً. من المحتمل أن يكون إعداد overwrite.cli.url أو trusted_proxies خاصّته غير صحيح.</string>
<string name="settings_licences_subtitle">المشاريع التي بنيت ‌‌Agendula عليها</string> <string name="settings_licences_subtitle">المشاريع التي بنيت ‌‌Agendula عليها</string>
<string name="licences_intro">يتضمن Agendula العمل أدناه. اضغط على أي إدخال للحصول على شفرة المصدر الخاصة به.</string> <string name="licences_intro">يتضمن Agendula العمل أدناه. اضغط على أي إدخال للحصول على شفرة المصدر الخاصة به.</string>
<string name="licences_footer">يتم تضمين بعض من هذا الكود في شكل معدل. أين يكون هذا هو الحال، يتم إدراج كل تغيير في مستودع المصدر المرتبط أعلاه.</string> <string name="licences_footer">يتم تضمين بعض من هذا الكود في شكل معدل. أين يكون هذا هو الحال، يتم إدراج كل تغيير في مستودع المصدر المرتبط أعلاه.</string>
@@ -661,106 +661,4 @@
<string name="list_save_failed_server">الخادم لن يقبل هذا التغيير.</string> <string name="list_save_failed_server">الخادم لن يقبل هذا التغيير.</string>
<string name="list_delete_confirm_message">سيتم حذف ”%1$s“ وجميع مهامه. هذا لا يمكن التراجع عنه.</string> <string name="list_delete_confirm_message">سيتم حذف ”%1$s“ وجميع مهامه. هذا لا يمكن التراجع عنه.</string>
<string name="lists_empty_action">إنشاء قائمة</string> <string name="lists_empty_action">إنشاء قائمة</string>
<string name="settings_dynamic_color_unavailable">متوفر على أندوريد ١٢ و أحدث</string>
<string name="settings_reminders_subtitle">التذكيرات و التسليم</string>
<string name="settings_form_fields_hint">الخانات المعروضة افتراضيًا على مهمة جديدة. الباقية تقع ضمن \"المزيد من الخانات\".</string>
<string name="settings_dynamic_color_hint">استخدام الألوان من خلفيتك</string>
<string name="settings_export_hint">حفظ قوائمك كملفات iCalendar</string>
<string name="settings_import_hint">إحضار المهام من ملف iCalendar</string>
<string name="settings_autofocus_title">تركيز العنوان على مهمة جديدة</string>
<string name="settings_autofocus_title_hint">عند بدء مهمة جديدة، ضع المؤشر في حقل العنوان وافتح لوحة المفاتيح على الفور.</string>
<string name="settings_storage_subtitle">أين مهامك تُبقى</string>
<string name="settings_allday_reminder_time">وقت تذكير اليوم الكامل</string>
<string name="settings_reliable_delivery_hint">أندرويد قد يؤخر التذكيرات لتوفير البطارية. استثنِ Agendula لكي تصل في الوقت المحدد.</string>
<string name="settings_reliable_delivery_exempt">معفى من تحسين البطارية — تصل التذكيرات في الوقت المحدّد.</string>
<string name="settings_translate_hint">إضافة أو تحسين لغة على Weblate</string>
<string name="settings_reminders_hint">أعلمني عندما تكون مهمة مستحقة</string>
<string name="settings_exact_alarms_allowed">تنطلق التذكيرات في الوقت المحدّد</string>
<string name="settings_notifications_blocked_hint">التذكيرات لا يمكنها الوصول لك. اضغط للسماح بإشعارات لـ Agendula.</string>
<string name="settings_exact_alarms_blocked">محظور — اضغط للسماح بالتذكيرات الدقيقة</string>
<string name="settings_default_list_first">أول قائمة متاحة</string>
<string name="settings_add_subtask_row">إظهار صف \"أضف مهمة فرعية\"</string>
<string name="settings_add_subtask_row_hint">خانة إضافة في نهاية المهام الفرعية للمهمة الموسعة</string>
<string name="settings_default_start_today">البداية الافتراضية إلى اليوم</string>
<string name="settings_default_start_today_hint">ملأ مسبق بداية مهمة جديدة بتاريخ اليوم</string>
<string name="settings_bottom_add_bar_hint">إضافة مهام من شريط مثبت إلى أسفل لقائمة، بدلاً من الزر العائم</string>
<string name="settings_task_store_hint">كل مخزن يحتفظ بمهامه الخاصة. التبديل لا يؤدي لنقلهم — انسخهم أولاً، أو صدِّرهم من النسخ الاحتياطي والاستعادة.</string>
<string name="settings_store_own_hint">التخزين خاصّة Agendula. لا شيء آخر للتثبيت.</string>
<string name="settings_store_external_hint">مشاركة المهام مع التطبيق الذي يزامنهم</string>
<string name="settings_store_external_missing">لا تطبيق مهام متوافق مثبّت</string>
<string name="accounts_remove_wipe">إزالة و حذف المهام</string>
<string name="list_delete_confirm_message_synced">”%1$s“ وجميع مهامه ستتم حذفها من الخادم، من كل جهاز آخر سُجِّل دخوله لهذا الحساب. هذا لا يمكن التراجع عنه.</string>
<plurals name="home_today_progress_count">
<item quantity="zero">تم %1$d من %2$d</item>
<item quantity="one">تم %1$d من %2$d</item>
<item quantity="two">تم %1$d من %2$d</item>
<item quantity="few">تم %1$d من %2$d</item>
<item quantity="many">تم %1$d من %2$d</item>
<item quantity="other">تم %1$d من %2$d</item>
</plurals>
<plurals name="home_today_remaining_count">
<item quantity="zero">تبقي %1$d</item>
<item quantity="one">تبقي %1$d</item>
<item quantity="two">تبقي %1$d</item>
<item quantity="few">تبقي %1$d</item>
<item quantity="many">تبقي %1$d</item>
<item quantity="other">تبقي %1$d</item>
</plurals>
<string name="list_where_device">على هذا الجهاز فقط</string>
<string name="list_read_only">تتم مشاركة هذه القائمة معك للقراءة فقط، لذلك فإن اسمها ولونها للمالك للتغيير.</string>
<string name="onboarding_no_provider_title">مُحتاج تطبيق مهام</string>
<string name="onboarding_permission_title">السماح بالوصول إلى مهامك</string>
<string name="onboarding_permission_body">Agendula يحتاج إذن لقراءة و كتابة مهامك. هذا هو الشيء الوحيد الذي يطلب له دائمًا.</string>
<string name="onboarding_permission_button">منح الوصول إلى المهام</string>
<string name="onboarding_welcome_sync_body">المزامنة عبر CalDAV مع الخادم أنت تختاره، أو الاحتفاظ بكل شيء على هذا الجهاز.</string>
<string name="onboarding_welcome_offline_title">لا إنترنت، بالتصميم</string>
<string name="onboarding_welcome_offline_body">هذا الإصدار من Agendula ليس لديه الوصول إلى الشبكة، لذلك فإنه لا يمكنه المزامنة أو الاتصال لأي شيء نفسه.</string>
<string name="onboarding_sync_title">مزامنة مع خادمك الخاص؟</string>
<string name="onboarding_sync_body">Agendula يدعم CalDAV — Nextcloud, Radicale, Posteo, Fastmail و أي شيء آخر يدعمه. لا حساب من خاصّتنا، أبدًا.</string>
<plurals name="widget_today_progress_cd">
<item quantity="zero">تم %1$d من %2$d</item>
<item quantity="one">تم %1$d من %2$d</item>
<item quantity="two">تم %1$d من %2$d</item>
<item quantity="few">تم %1$d من %2$d</item>
<item quantity="many">تم %1$d من %2$d</item>
<item quantity="other">تم %1$d من %2$d</item>
</plurals>
<plurals name="sync_notice_more">
<item quantity="zero">و (%1$d) لا أخرى</item>
<item quantity="one">و %1$d آخر</item>
<item quantity="two">و %1$d آخران</item>
<item quantity="few">و %1$d أخرى</item>
<item quantity="many">و %1$d آخر</item>
<item quantity="other">و %1$d آخر</item>
</plurals>
<string name="onboarding_no_provider_body">أنت اخترت للاحتفاظ بالمهام في تطبيق آخر، لكن لا واحد متوافق مثبّت. قم بتثبيت OpenTasks أو tasks.org، أو قم بالتبديل عودتًا إلى التخزين خاصّة Agendula.</string>
<string name="onboarding_sync_connect">ربط حساب</string>
<string name="onboarding_sync_devices_title">كل جهاز في وقت واحد</string>
<string name="onboarding_sync_devices_body">ضع علامة صح لشيء ما هنا و سيتم وضع العلامة عليه في كل مكان.</string>
<string name="onboarding_sync_copy_title">نسخة تبقَى بعد الهاتف</string>
<string name="onboarding_sync_copy_body">تعيش مهامك على الخادم أيضًا، لذلك جهاز مفقود لا يعني فقدان العمل.</string>
<string name="onboarding_sync_yours_title">لك الاختيار</string>
<string name="onboarding_sync_yours_body">مستضاف ذاتيًا (Self-hosted) أو مزود أنت تدفع له بالفعل — Agendula فقط يخزن كلمة المرور.</string>
<string name="onboarding_list_title">قائمتك الأولى</string>
<string name="onboarding_list_body">تحافظ القوائم على فصل الأشياء. ابدأ بواحدة — يمكنك إضافة المزيد، وإعادة تسمية هذه القائمة، أيًّا كان الوقت.</string>
<string name="onboarding_backup_title">احتفظ بنسخة</string>
<string name="settings_section_backup">النسخ الاحتياطي والاستعادة</string>
<string name="onboarding_backup_body_later">دون حساب، قوائمك تعيش فقط على هذا الجهاز. احفظهم كملفات iCalendar أي تطبيق مهام أو تقويم آخر يمكنه قراءتها، أيّا كان الوقت من الإعدادات ← النسخ الاحتياطي والاستعادة ← تصدير.</string>
<string name="onboarding_quick_add_title">كيف ترغب في إضافة المهام؟</string>
<string name="onboarding_quick_add_bar">شريط الإضافة السريع</string>
<string name="onboarding_quick_add_bar_hint">خانة في أسفل كل قائمة: اكتب، اضغط إدخال، إلى التالي.</string>
<string name="onboarding_quick_add_button">زر مهمة جديدة</string>
<string name="onboarding_quick_add_button_hint">زر عائم يفتح النموذج الكامل.</string>
<string name="onboarding_exact_alarms_title">صحيح على الوقت</string>
<string name="onboarding_exact_alarms_body">أندوريد يسأل قبل أن يتمكن تطبيق من إعلامك في دقيقة محددة. اسمح به والتذكيرات تصل في موعد الاستحقاق خاصّتهم — بدونه قد يتأخروا بضع دقائق.</string>
<string name="onboarding_exact_alarms_allow">السماح بالتوقيت الدقيق</string>
<string name="onboarding_use_own_store">استخدم تخزين هذا الجهاز بدلاً من ذلك</string>
<string name="widget_config_theme_follow_app">اتبع التطبيق</string>
<string name="widget_config_theme_light">فاتح</string>
<string name="widget_config_theme_dark">داكن</string>
<string name="widget_config_row_color">ألوان خام</string>
<string name="widget_config_row_color_neutral">محايد</string>
<string name="widget_config_background">الخلفية</string>
<string name="widget_config_background_solid">سادة</string>
<string name="widget_config_theme">السمة</string>
</resources> </resources>
+4 -209
View File
@@ -68,7 +68,7 @@
<string name="settings_translate_hint">Přidejte nový jazyk nebo vylepšete překlad na Weblate</string> <string name="settings_translate_hint">Přidejte nový jazyk nebo vylepšete překlad na Weblate</string>
<string name="settings_translate">Pomozte s překladem</string> <string name="settings_translate">Pomozte s překladem</string>
<string name="onboarding_no_provider_body">Rozhodli jste se ukládat úkoly v jiné aplikaci, ale nemáte nainstalovanou žádnou kompatibilní aplikaci. Nainstalujte OpenTasks nebo tasks.org, případně přepněte zpět na vlastní úložiště aplikace Agendula.</string> <string name="onboarding_no_provider_body">Rozhodli jste se ukládat úkoly v jiné aplikaci, ale nemáte nainstalovanou žádnou kompatibilní aplikaci. Nainstalujte OpenTasks nebo tasks.org, případně přepněte zpět na vlastní úložiště aplikace Agendula.</string>
<string name="onboarding_backup_body_later">Pokud nemáte účet, vaše seznamy jsou uložené pouze v tomto zařízení. Kdykoli je můžete uložit jako soubory iCalendar, které lze otevřít v jiných aplikacích pro úkoly nebo kalendář. Stačí přejít do Nastavení → Zálohování a obnovení → Export.</string> <string name="onboarding_backup_body_later">Pokud nemáte účet, vaše seznamy jsou uložené pouze v tomto zařízení. Kdykoli je můžete exportovat jako soubory iCalendar, které lze otevřít v jiných aplikacích pro úkoly nebo kalendář. Stačí přejít do Nastavení → Úložiště → Exportovat úkoly.</string>
<string name="onboarding_use_own_store">Používat úložiště tohoto zařízení</string> <string name="onboarding_use_own_store">Používat úložiště tohoto zařízení</string>
<string name="settings_section_storage">Úložiště</string> <string name="settings_section_storage">Úložiště</string>
<string name="settings_storage_subtitle">Kde jsou úkoly uložené a jak je exportovat</string> <string name="settings_storage_subtitle">Kde jsou úkoly uložené a jak je exportovat</string>
@@ -76,9 +76,9 @@
<string name="export_to_folder">Uložit do adresáře</string> <string name="export_to_folder">Uložit do adresáře</string>
<string name="export_to_zip">Uložit jako ZIP soubor</string> <string name="export_to_zip">Uložit jako ZIP soubor</string>
<string name="settings_export">Exportovat úkoly</string> <string name="settings_export">Exportovat úkoly</string>
<string name="settings_about_support">Podpořit vývoj</string> <string name="settings_about_support">Podpora vývoje</string>
<string name="settings_section_appearance">Vzhled</string> <string name="settings_section_appearance">Vzhled</string>
<string name="settings_appearance_subtitle">Motiv, barvy, formát času</string> <string name="settings_appearance_subtitle">Motiv, barva a formát času</string>
<string name="settings_theme">Motiv</string> <string name="settings_theme">Motiv</string>
<string name="settings_theme_system">Dle systému</string> <string name="settings_theme_system">Dle systému</string>
<string name="settings_theme_light">Světlý</string> <string name="settings_theme_light">Světlý</string>
@@ -94,7 +94,7 @@
<string name="settings_time_format_auto">Automaticky</string> <string name="settings_time_format_auto">Automaticky</string>
<string name="settings_time_format_auto_summary">Podle systému: %1$s</string> <string name="settings_time_format_auto_summary">Podle systému: %1$s</string>
<string name="settings_time_format_12h">12hodinový (2:00 PM)</string> <string name="settings_time_format_12h">12hodinový (2:00 PM)</string>
<string name="settings_time_format_24h">24hodinový (14:00)</string> <string name="settings_time_format_24h">24hodinový formát (14:00)</string>
<string name="settings_week_start">Víkend začíná v</string> <string name="settings_week_start">Víkend začíná v</string>
<string name="settings_week_start_auto">Automaticky</string> <string name="settings_week_start_auto">Automaticky</string>
<string name="settings_group_look">Vzhled a chování</string> <string name="settings_group_look">Vzhled a chování</string>
@@ -115,209 +115,4 @@
<string name="list_color">Barva</string> <string name="list_color">Barva</string>
<string name="widget_config_row_color">Barva řádků</string> <string name="widget_config_row_color">Barva řádků</string>
<string name="widget_config_row_color_by_list_hint">Každý řádek je lehce zabarven barvou svého seznamu</string> <string name="widget_config_row_color_by_list_hint">Každý řádek je lehce zabarven barvou svého seznamu</string>
<string name="settings_section_task_form">Formulář úkolu</string>
<string name="settings_task_form_subtitle">Výchozí pole, seznam a podúkoly</string>
<string name="recurrence_title">Opakování</string>
<string name="home_today_all_done">Vše hotovo 🎉</string>
<string name="edit_start_label">Začátek</string>
<string name="detail_due">Termín splnění</string>
<string name="edit_due_label">Termín splnění</string>
<string name="error_due_before_start">Termín splnění nemůže předcházet začátku.</string>
<string name="error_recurrence_without_date">Opakující se úkol vyžaduje datum začátku nebo termín splnění.</string>
<string name="filter_section_due">Termín splnění</string>
<string name="filter_any_due">Jakýkoli termín splnění</string>
<string name="home_due_today">Dnes</string>
<string name="home_due_tomorrow">Zítra</string>
<string name="reminder_onboarding_body">Agendula vás upozorní, když nastane čas něco splnit, takže si nemusíte všechno pamatovat.</string>
<string name="onboarding_exact_alarms_body">Android se předem zeptá, zda aplikaci povolíte posílat upozornění v přesně stanovený čas. Pokud to povolíte, připomínky dorazí včas. Bez tohoto oprávnění se mohou o několik minut opozdit.</string>
<string name="settings_week_start_auto_summary">Aktuálně %1$s</string>
<string name="settings_group_about">O aplikaci</string>
<string name="settings_about_author">od Jean-Luc Makiola</string>
<string name="settings_about_version">Verze %1$s</string>
<string name="settings_about_logo_desc">Ikona aplikace Agendula</string>
<string name="settings_group_app">Aplikace</string>
<string name="settings_section_backup">Zálohování a obnovení</string>
<string name="settings_backup_subtitle">Export a import</string>
<string name="import_title">Importovat úkoly</string>
<plurals name="import_found">
<item quantity="one">%1$d úkol</item>
<item quantity="few">%1$d úkolů</item>
<item quantity="many">%1$d úkolů</item>
<item quantity="other">%1$d úkolů</item>
</plurals>
<string name="settings_export_hint">Uložit seznamy jako soubory iCalendar</string>
<string name="settings_import_hint">Importovat úkoly ze souboru iCalendar</string>
<string name="import_pick">Vybrat soubor</string>
<string name="import_pick_other">Vybrat jiný soubor</string>
<string name="import_hint">Vyberte soubor .ics nebo archiv ZIP obsahující více takových souborů, jaký vytváří export. Zachovají se opakování, podúkoly i všechny ostatní údaje úkolů. Úkoly, které už ve vybraném seznamu jsou, budou přeskočeny.</string>
<string name="import_pick_another">Importovat jiný soubor</string>
<string name="add_account_provider_body">Vyberte službu, ve které máte uložené seznamy úkolů. Agendula se synchronizuje se všemi službami podporujícími protokol CalDAV.</string>
<string name="add_account_lists_body">Vyberte seznamy úkolů, které chcete synchronizovat. Toto nastavení můžete později změnit.</string>
<string name="settings_copy_from_external">Kopírovat úkoly z %1$s</string>
<string name="onboarding_welcome_sync_body">Úkoly můžete synchronizovat přes CalDAV s vlastním serverem, nebo je uchovávat pouze v tomto zařízení.</string>
<string name="settings_store_own">Na tomto zařízení</string>
<string name="import_new_list_hint">Nový seznam na tomto zařizení</string>
<string name="settings_task_store_hint">Každé úložiště uchovává vlastní úkoly. Přepnutím mezi úložišti se úkoly nepřesunou. Nejprve je zkopírujte do druhého úložiště nebo je exportujte v sekci Zálohování a obnovení.</string>
<string name="settings_store_permission_denied_hint">K úkolům v jiné aplikaci nebude možné přistupovat, dokud nepovolíte přístup. Klepnutím otevřete nastavení aplikace.</string>
<string name="settings_section_accounts">Účty</string>
<string name="accounts_sync_interval">Synchronizovat automaticky</string>
<string name="accounts_sync_interval_hint">Jak často se jednotlivé účty synchronizují na pozadí. Změny, které provedete, se v každém případě odešlou do jedné minuty. Android může interval prodloužit, aby šetřil baterii.</string>
<string name="reminder_unit_minutes">Minuty</string>
<plurals name="duration_minutes">
<item quantity="one">%d minuta</item>
<item quantity="few">%d minut</item>
<item quantity="many">%d minut</item>
<item quantity="other">%d minut</item>
</plurals>
<plurals name="accounts_sync_every_minutes">
<item quantity="one">Každou %d minutu</item>
<item quantity="few">Každé %d minuty</item>
<item quantity="many">Každých %d minut</item>
<item quantity="other">Každých %d minut</item>
</plurals>
<string name="add_account_browser_error_rate_limited">Server dočasně odmítá další pokusy. Počkejte několik minut a zkuste to znovu.</string>
<plurals name="duration_hours">
<item quantity="one">%d hodina</item>
<item quantity="few">%d hodin</item>
<item quantity="many">%d hodin</item>
<item quantity="other">%d hodin</item>
</plurals>
<plurals name="reminder_hours">
<item quantity="one">Před %1$d hodinou</item>
<item quantity="few">Před %1$d hodinami</item>
<item quantity="many">Před %1$d hodinami</item>
<item quantity="other">Před %1$d hodinami</item>
</plurals>
<plurals name="reminder_hours_before_start">
<item quantity="one">%1$d hodina před začátkem</item>
<item quantity="few">%1$d hodiny před začátkem</item>
<item quantity="many">%1$d hodin před začátkem</item>
<item quantity="other">%1$d hodin před začátkem</item>
</plurals>
<plurals name="accounts_sync_every_hours">
<item quantity="one">Každou hodinu</item>
<item quantity="few">Každé %d hodiny</item>
<item quantity="many">Každých %d hodin</item>
<item quantity="other">Každých %d hodin</item>
</plurals>
<string name="accounts_sync_interval_manual">Jen při synchronizaci</string>
<string name="accounts_push">Okamžité doručování</string>
<string name="accounts_push_off">Vypnuto</string>
<string name="accounts_push_unsupported">Váš server zatím nepodporuje okamžité doručování</string>
<string name="accounts_push_no_distributor">Nainstalujte si aplikaci podporující UnifiedPush, abyste dostávali změny okamžitě</string>
<string name="accounts_push_hint">Pokud máte nainstalovanou aplikaci podporující UnifiedPush, například ntfy, změny provedené na ostatních zařízeních se projeví během několika sekund, místo aby se zobrazily až při další synchronizaci. Váš server musí podporovat WebDAV-Push. V případě Nextcloudu tuto funkci zajišťuje aplikace WebDAV Push.</string>
<string name="accounts_push_none_installed">Nemáte nainstalovanou žádnou aplikaci podporující UnifiedPush. Nainstalujte si například ntfy, NextPush nebo Sunup a její nabídka se zobrazí zde.</string>
<string name="onboarding_sync_body">Agendula komunikuje přes CalDAV – podporuje Nextcloud, Radicale, Posteo, Fastmail i jakoukoli jinou službu, která tento protokol používá. U nás si nikdy nemusíte zakládat účet.</string>
<string name="onboarding_sync_connect">Připojit účet</string>
<string name="accounts_empty_body">Přidejte účet CalDAV a Agendula bude synchronizovat vaše seznamy úkolů s tímto účtem. Podporuje Nextcloud, Radicale, Baïkal i jakoukoli jinou službu, která používá tento protokol.</string>
<string name="accounts_add">Přidat účet</string>
<string name="accounts_failed_misconfigured">Synchronizace selhala – účet není kompletní. Odeberte ho a přidejte znovu</string>
<string name="add_account_summary_body">Agendula bude odteď tyto seznamy synchronizovat. Které seznamy se synchronizují, můžete změnit v Nastavení.</string>
<string name="add_account_server_body">Zadejte adresu svého serveru CalDAV. Pokud server používá jako svou adresu vlastní doménu, můžete zadat také e-mailovou adresu.</string>
<string name="add_account_credentials_body">Pokud váš poskytovatel tuto možnost nabízí, použijte heslo pro aplikaci. Lze ho kdykoli zrušit, aniž byste museli měnit heslo ke svému účtu.</string>
<string name="add_account_error_not_dav">Zadaná adresa odpověděla, ale nejedná se o server CalDAV. Pokud jste vložili adresu webové stránky, zkuste použít adresu serveru, kterou váš poskytovatel uvádí pro kalendáře.</string>
<string name="add_account_error_unreachable">K serveru se nepodařilo připojit. Zkontrolujte adresu a připojení k internetu.</string>
<string name="add_account_error_server">Na straně serveru došlo k chybě. Zkuste to za chvíli znovu.</string>
<string name="add_account_error_google_unsupported">Kalendář Google nepodporuje synchronizaci úkolů přes CalDAV, jak uvádí i jeho vlastní dokumentace. Agendula s ním proto nemůže synchronizovat úkoly.</string>
<string name="add_account_error_cross_domain">Tento server uchovává některé seznamy úkolů na adrese %1$s, ke kterým se Agendula zatím nedokáže přihlásit.</string>
<string name="add_account_error_no_usable_lists">Přihlášení proběhlo úspěšně, ale tento účet neobsahuje žádné seznamy úkolů, které může Agendula používat.</string>
<string name="add_account_browser_error_expired">Časový limit pro schválení vypršel dříve, než server odpověděl.</string>
<string name="add_account_browser_error_maintenance">Server je v režimu údržby. Zkuste to znovu, až bude údržba dokončena.</string>
<string name="add_account_browser_error_unavailable">V tomto zařízení není prohlížeč, který by dokázal otevřít přihlašovací stránku. Použijte místo toho heslo.</string>
<string name="add_account_quirk_hint_fastmail">Fastmail vyžaduje heslo pro aplikaci, nikoli heslo k vašemu účtu. Protokol CalDAV navíc není součástí tarifu Basic.</string>
<string name="add_account_quirk_hint_icloud">iCloud vyžaduje heslo pro konkrétní aplikaci. Vytvoříte ho na adrese appleid.apple.com, pokud máte zapnuté dvoufaktorové ověřování.</string>
<string name="add_account_setup_icloud_title">iCloud vyžaduje heslo pro konkrétní aplikaci</string>
<string name="add_account_setup_fastmail_title">Fastmail vyžaduje heslo pro aplikaci</string>
<string name="add_account_setup_fastmail_footnote">CalDAV není součástí tarifu Fastmail Basic.</string>
<string name="add_account_password_hint">Pokud váš server tuto možnost nabízí, použijte heslo pro aplikaci – můžete ho zrušit, aniž byste museli měnit heslo ke svému účtu.</string>
<string name="add_account_browser_body">Schvalte Agendulu v prohlížeči a pak se vraťte sem. Vaše heslo se této aplikaci nikdy neodesílá – server vytvoří samostatné heslo pro aplikaci, které můžete kdykoli zrušit.</string>
<string name="add_account_browser_insecure">Přihlašovací stránka není zabezpečená šifrováním (HTTP). Vše, co na ní zadáte, včetně hesla k účtu, se přenáší nešifrovaně.</string>
<string name="add_account_browser_host_mismatch">Server nás přesměroval na adresu %1$s, ale vy jste zadali adresu %2$s. To obvykle znamená, že nastavení `overwrite.cli.url` není správné.</string>
<string name="add_account_origin_mismatch">Server oznámil svou adresu jako %1$s, na kterou se toto zařízení nemůže připojit, proto byla použita adresa %2$s. Nastavení `overwrite.cli.url` nebo `trusted_proxies` serveru je pravděpodobně nesprávné.</string>
<string name="cd_expand_subtasks">Ukázat podúkoly</string>
<string name="cd_collapse_subtasks">Skrýt podúkoly</string>
<string name="detail_subtasks">Podúkoly</string>
<plurals name="detail_delete_body_subtasks">
<item quantity="one">Tento úkol a jeho podúkol budou smazány.</item>
<item quantity="few">Tento úkol a jeho %1$d podúkoly budou smazány.</item>
<item quantity="many">Tento úkol a jeho %1$d podúkolů budou smazány.</item>
<item quantity="other">Tento úkol a jeho %1$d podúkolů budou smazány.</item>
</plurals>
<string name="recurrence_none">Neopakovat</string>
<string name="edit_recurrence_label">Opakování</string>
<string name="edit_recurrence_none">Neopakovat</string>
<string name="reminder_custom">Vlastní</string>
<string name="reminder_custom_with_value">Vlastní (%1$s)</string>
<string name="recurrence_weekly">Každý týden</string>
<plurals name="recurrence_every_n_weeks">
<item quantity="one">Každý %1$d týden</item>
<item quantity="few">Každé %1$d týdny</item>
<item quantity="many">Každých %1$d týdnů</item>
<item quantity="other">Každých %1$d týdnů</item>
</plurals>
<plurals name="reminder_weeks_before_start">
<item quantity="one">%1$d týden před začátkem</item>
<item quantity="few">%1$d týdny před začátkem</item>
<item quantity="many">%1$d týdnů před začátkem</item>
<item quantity="other">%1$d týdnů před začátkem</item>
</plurals>
<plurals name="reminder_weeks_before_due">
<item quantity="one">%1$d týden před termínem splnění</item>
<item quantity="few">%1$d týdny před termínem splnění</item>
<item quantity="many">%1$d týdnů před termínem splnění</item>
<item quantity="other">%1$d týdnů před termínem splnění</item>
</plurals>
<string name="recurrence_incomplete">Zadejte číslo od 1 do 999</string>
<string name="recurrence_end_count">Po určitém počtu opakování</string>
<string name="recurrence_end_until">V konkrétní den</string>
<string name="error_reminder_without_date">Pro nastavení připomínky zadejte datum začátku nebo termín splnění.</string>
<string name="reminder_on_start_date">V den začátku</string>
<string name="reminder_on_due_date">V den termínu splnění</string>
<string name="reminder_anchor_due">Od termínu splnění</string>
<string name="filters_empty_hint">Kombinujte termíny splnění, seznamy, štítky a priority do vlastního přehledu</string>
<string name="settings_default_start_today_hint">Předvyplnit datum začátku nového úkolu dnešním datem</string>
<plurals name="reminder_minutes_before_due">
<item quantity="one">%1$d minutu před termínem splnění</item>
<item quantity="few">%1$d minut před termínem splnění</item>
<item quantity="many">%1$d minut před termínem splnění</item>
<item quantity="other">%1$d minut před termínem splnění</item>
</plurals>
<plurals name="reminder_hours_before_due">
<item quantity="one">%1$d hodinu před termínem splnění</item>
<item quantity="few">%1$d hodin před termínem splnění</item>
<item quantity="many">%1$d hodin před termínem splnění</item>
<item quantity="other">%1$d hodin před termínem splnění</item>
</plurals>
<plurals name="reminder_days_before_due">
<item quantity="one">%1$d den před termínem splnění</item>
<item quantity="few">%1$d dny před termínem splnění</item>
<item quantity="many">%1$d dní před termínem splnění</item>
<item quantity="other">%1$d dní před termínem splnění</item>
</plurals>
<string name="clear_completed_confirm">Smazat</string>
<string name="detail_delete_title">Smazat úkol?</string>
<string name="detail_delete_body">Tento úkol bude smazán.</string>
<string name="filter_delete_title">Smazat “%1$s”?</string>
<string name="list_delete_confirm_title">Smazat seznam?</string>
<string name="list_delete_confirm_message_synced">„%1$s“ a všechny jeho úkoly budou smazány ze serveru i ze všech ostatních zařízení přihlášených k tomuto účtu. Tuto akci nelze vrátit zpět.</string>
<string name="list_delete_confirm_message">„%1$s“ a všechny jeho úkoly budou smazány. Tuto akci nelze vrátit zpět.</string>
<string name="accounts_remove_body">%1$s se přestane synchronizovat s tímto zařízením. Na serveru se nic nesmaže.</string>
<string name="accounts_remove_wipe_body">Z tohoto zařízení se zároveň smažou všechny seznamy a úkoly patřící k tomuto účtu.</string>
<string name="accounts_lists_drop_body">U seznamů, u kterých jste zrušili zaškrtnutí, se zastaví synchronizace s tímto zařízením. Na serveru se nic nesmaže.</string>
<string name="accounts_lists_drop_wipe_body">Z tohoto zařízení se zároveň smažou vybrané seznamy i všechny jejich úkoly.</string>
<string name="sync_notice_cause_delete_lost">Tady jste ho smazali, ale na serveru byl mezitím změněn</string>
<string name="permission_write_tasks_description">Umožňuje aplikaci přidávat, upravovat, označovat jako dokončené a mazat úkoly uložené v Agendule, včetně jejich připomínek a štítků. Seznamy úkolů upravovat ani mazat nemůže.</string>
<string name="edit_set">Nastavit</string>
<string name="reminder_custom_set">Nastavit</string>
<string name="settings_list_reminders_hint">Přepíše výchozí nastavení pro každý seznam zvlášť, a to samostatně pro úkoly s konkrétním časem a celodenní úkoly. Seznam může převzít výchozí nastavení, nepoužívat žádné nebo mít vlastní nastavení.</string>
<string name="settings_list_notifications">Zvuk a vibrace</string>
<string name="settings_list_notifications_hint">Zvuk, vibrace a důležitost připomínek pro tento seznam</string>
<string name="detail_duplicate">Vytvořit kopii</string>
<string name="detail_status_cancelled">Zrušeno</string>
<string name="detail_cancel_task">Zrušit úkol</string>
<string name="detail_restore">Obnovit úkol</string>
<string name="edit_reminder_label">Upozornění</string>
<string name="reminder_at_due">V čase úkolu</string>
<string name="reminder_on_the_day">V daný den</string>
<string name="settings_list_reminders_title">Upozornění pro jednotlivé seznamy</string>
</resources> </resources>
-15
View File
@@ -442,19 +442,4 @@
<string name="recurring_scope_series">Alle Termine der Serie</string> <string name="recurring_scope_series">Alle Termine der Serie</string>
<string name="edit_recurrence_label">Wiederholung</string> <string name="edit_recurrence_label">Wiederholung</string>
<string name="edit_recurrence_none">Wiederholt sich nicht</string> <string name="edit_recurrence_none">Wiederholt sich nicht</string>
<string name="reminder_time">Zeit</string>
<string name="filters_header">Filter</string>
<string name="filter_new_title">Neuer Filter</string>
<string name="filter_edit_title">Filter bearbeiten</string>
<string name="filter_name_hint">Name des Filters</string>
<string name="filter_section_due">Fällig</string>
<string name="filter_section_priority">Priorität</string>
<string name="filter_section_lists">Listen</string>
<string name="filter_priority_low">Niedriger und höher</string>
<string name="filter_priority_medium">Mittel und höher</string>
<string name="filter_priority_high">Nur hoch</string>
<string name="filter_delete">Filter löschen</string>
<string name="filter_delete_title">“%1$s” löschen?</string>
<string name="filter_delete_confirm">Löschen</string>
<string name="filter_missing">Filter gelöscht</string>
</resources> </resources>
@@ -1,4 +0,0 @@
<resources>
<!-- Nearest to the dynamic scheme's background, tone 6 of the neutral variant. -->
<color name="window_background">@android:color/system_neutral2_900</color>
</resources>
@@ -1,3 +0,0 @@
<resources>
<color name="window_background">@android:color/system_background_dark</color>
</resources>
-3
View File
@@ -1,3 +0,0 @@
<resources>
<bool name="light_system_bars">false</bool>
</resources>
-3
View File
@@ -1,3 +0,0 @@
<resources>
<color name="window_background_fallback">#FF161113</color>
</resources>
-31
View File
@@ -1,31 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Agendula</string>
<string name="task_untitled">Задача без имени</string>
<string name="back">Назад</string>
<string name="save">Сохранить</string>
<string name="delete">Удалить</string>
<string name="tasks_title">Задачи</string>
<string name="task_list_empty">Здесь пока нет задач.</string>
<string name="task_list_failure">Невозможно загрузить эти задачи.</string>
<string name="smart_no_date">Без даты</string>
<string name="smart_completed">Завершено</string>
<string name="task_detail_title">Задача</string>
<string name="task_detail_not_found">Задачи больше не существует.</string>
<string name="edit">Редактировать</string>
<string name="field_description">Описание</string>
<string name="edit_save_failed">Не удалось сохранить. Попробуйте снова.</string>
<string name="add_task_hint">Добавить задачу</string>
<string name="add_subtask_hint">Добавить подзадачу</string>
<string name="cd_add_task">Добавить задачу</string>
<string name="cd_complete">Завершить</string>
<string name="cd_reopen">Открыть заново</string>
<string name="task_deleted">Удалено</string>
<string name="task_completed">Завершено</string>
<string name="cd_repeats">Повторить</string>
<string name="shortcut_new_task">Новая задача</string>
<string name="shortcut_today">Сегодня</string>
<string name="qs_tile_new_task">Новая задача</string>
<string name="lists_reorder">Переопределить список</string>
<string name="lists_reorder_done">Завершить переопределение</string>
</resources>
-3
View File
@@ -1,3 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
</resources>
-4
View File
@@ -1,4 +0,0 @@
<resources>
<!-- Nearest to the dynamic scheme's background, tone 98 of the neutral variant. -->
<color name="window_background">@android:color/system_neutral2_10</color>
</resources>
-3
View File
@@ -1,3 +0,0 @@
<resources>
<color name="window_background">@android:color/system_background_light</color>
</resources>
@@ -1,3 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
</resources>
-3
View File
@@ -1,3 +0,0 @@
<resources>
<bool name="light_system_bars">true</bool>
</resources>
-4
View File
@@ -2,8 +2,4 @@
<!-- Seed color: warm mauve. Material 3 derives Light & Dark schemes from this. --> <!-- Seed color: warm mauve. Material 3 derives Light & Dark schemes from this. -->
<!-- Adaptive icon background --> <!-- Adaptive icon background -->
<color name="ic_launcher_background">#FF7A5C6B</color> <color name="ic_launcher_background">#FF7A5C6B</color>
<!-- The Compose fallback schemes' background, kept equal by WindowColorsTest. -->
<color name="window_background_fallback">#FFFEFBFC</color>
<!-- The launch window and splash: the dynamic background where there is one. -->
<color name="window_background">@color/window_background_fallback</color>
</resources> </resources>
-24
View File
@@ -1,6 +1,5 @@
<resources> <resources>
<string name="app_name">Agendula</string> <string name="app_name">Agendula</string>
<string name="app_name_tasks_alias">Tasks</string>
<!-- Generic --> <!-- Generic -->
<string name="task_untitled">Untitled task</string> <string name="task_untitled">Untitled task</string>
@@ -451,8 +450,6 @@
<string name="settings_theme_dark">Dark</string> <string name="settings_theme_dark">Dark</string>
<string name="settings_theme_black">Black</string> <string name="settings_theme_black">Black</string>
<string name="settings_theme_black_summary">Dark with pure black backgrounds, for AMOLED screens</string> <string name="settings_theme_black_summary">Dark with pure black backgrounds, for AMOLED screens</string>
<string name="settings_app_name">App name</string>
<string name="settings_app_name_hint">Show Agendula as “Tasks” in your launcher. Only the launcher name changes; the icon may move to a new spot. Agendula closes when you switch, so reopen it from the launcher.</string>
<string name="settings_dynamic_color">Dynamic colour</string> <string name="settings_dynamic_color">Dynamic colour</string>
<string name="settings_dynamic_color_hint">Use colours from your wallpaper</string> <string name="settings_dynamic_color_hint">Use colours from your wallpaper</string>
<string name="settings_section_reminders">Notifications</string> <string name="settings_section_reminders">Notifications</string>
@@ -686,11 +683,6 @@
<string name="accounts_remove_wipe_body">Also deletes this account\u2019s lists and tasks from this device.</string> <string name="accounts_remove_wipe_body">Also deletes this account\u2019s lists and tasks from this device.</string>
<string name="accounts_never_synced">Never synced</string> <string name="accounts_never_synced">Never synced</string>
<string name="accounts_last_sync">Last sync</string> <string name="accounts_last_sync">Last sync</string>
<string name="accounts_name">Name</string>
<string name="accounts_name_hint">How this account is shown in Agendula. Leave it empty to use “%1$s”.</string>
<string name="accounts_connection">Connection</string>
<string name="accounts_connection_cleartext">Unencrypted (http)</string>
<string name="accounts_trusted_certificate">Trusted certificate</string>
<string name="accounts_generic_provider">CalDAV account</string> <string name="accounts_generic_provider">CalDAV account</string>
<string name="accounts_sync_failed">Last sync didn\u2019t finish</string> <string name="accounts_sync_failed">Last sync didn\u2019t finish</string>
<string name="accounts_failed_sign_in">Sync failed \u2014 the server didn\u2019t accept the password</string> <string name="accounts_failed_sign_in">Sync failed \u2014 the server didn\u2019t accept the password</string>
@@ -740,7 +732,6 @@
<string name="add_account_error_insecure">That address is unencrypted. Agendula only sends your password over https.</string> <string name="add_account_error_insecure">That address is unencrypted. Agendula only sends your password over https.</string>
<string name="add_account_error_no_calendars">Signed in, but this account has no task lists.</string> <string name="add_account_error_no_calendars">Signed in, but this account has no task lists.</string>
<string name="add_account_error_server">The server ran into a problem. Try again in a moment.</string> <string name="add_account_error_server">The server ran into a problem. Try again in a moment.</string>
<string name="add_account_error_tls">Couldn\u2019t set up a secure connection to that server.</string>
<string name="add_account_working_discovering">Looking for a CalDAV server</string> <string name="add_account_working_discovering">Looking for a CalDAV server</string>
<string name="add_account_working_signing_in">Signing in</string> <string name="add_account_working_signing_in">Signing in</string>
<string name="add_account_working_reading_lists">Reading your task lists</string> <string name="add_account_working_reading_lists">Reading your task lists</string>
@@ -819,21 +810,6 @@
<string name="add_account_browser_host_mismatch">The server sent us to %1$s, but you typed %2$s. That usually means its overwrite.cli.url setting is wrong.</string> <string name="add_account_browser_host_mismatch">The server sent us to %1$s, but you typed %2$s. That usually means its overwrite.cli.url setting is wrong.</string>
<string name="add_account_origin_mismatch">The server reported its address as %1$s, which this device cannot reach, so %2$s was used instead. Its overwrite.cli.url or trusted_proxies setting is probably wrong.</string> <string name="add_account_origin_mismatch">The server reported its address as %1$s, which this device cannot reach, so %2$s was used instead. Its overwrite.cli.url or trusted_proxies setting is probably wrong.</string>
<string name="add_account_cleartext_title">Connect without encryption?</string>
<string name="add_account_cleartext_body">This address uses http, not https. Your password and your tasks would travel unencrypted, and anyone on the network between this phone and the server could read them.</string>
<string name="add_account_cleartext_note">That can be fine for a server you only reach over a VPN or your home network. Anywhere else, set up https on the server instead.</string>
<string name="add_account_cleartext_connect">Connect anyway</string>
<string name="add_account_change_address">Change the address</string>
<string name="add_account_certificate_title">Trust this certificate?</string>
<string name="add_account_certificate_body">This server\u2019s certificate isn\u2019t signed by an authority your phone trusts. If you run this server yourself, compare the fingerprint below with your server\u2019s certificate before trusting it.</string>
<string name="add_account_certificate_fingerprint">SHA-256 fingerprint</string>
<string name="add_account_certificate_subject">Issued to</string>
<string name="add_account_certificate_issuer">Issued by</string>
<string name="add_account_certificate_expires">Valid until</string>
<string name="add_account_certificate_trust">Trust this certificate</string>
<string name="add_account_certificate_decline">Don\u2019t trust it</string>
<string name="add_account_lists_title">Which lists should sync?</string> <string name="add_account_lists_title">Which lists should sync?</string>
<string name="add_account_lists_read_only">Read-only</string> <string name="add_account_lists_read_only">Read-only</string>
<string name="add_account_lists_shared">Shared with you</string> <string name="add_account_lists_shared">Shared with you</string>
+2 -14
View File
@@ -1,19 +1,7 @@
<resources xmlns:tools="http://schemas.android.com/tools"> <resources xmlns:tools="http://schemas.android.com/tools">
<!-- The launch window and splash; Compose draws the rest. Its light or dark <style name="Theme.Agendula" parent="android:Theme.Material.Light.NoActionBar">
follows the app's theme setting, handed to the system by SystemTheme. -->
<style name="Theme.Agendula" parent="Theme.AppCompat.DayNight.NoActionBar">
<item name="android:windowBackground">@color/window_background</item>
<item name="android:statusBarColor">@android:color/transparent</item> <item name="android:statusBarColor">@android:color/transparent</item>
<item name="android:navigationBarColor">@android:color/transparent</item> <item name="android:navigationBarColor">@android:color/transparent</item>
<item name="android:windowLightStatusBar">@bool/light_system_bars</item> <item name="android:windowLightStatusBar">true</item>
</style>
<!-- Splash themes picked at runtime on API 33+, see SystemTheme. -->
<style name="Theme.Agendula.Fallback">
<item name="android:windowBackground">@color/window_background_fallback</item>
</style>
<style name="Theme.Agendula.Black">
<item name="android:windowBackground">@android:color/black</item>
</style> </style>
</resources> </resources>
-4
View File
@@ -10,8 +10,4 @@
<locale android:name="en" /> <locale android:name="en" />
<locale android:name="de" /> <locale android:name="de" />
<locale android:name="pt-BR" /> <locale android:name="pt-BR" />
<locale android:name="fr" />
<locale android:name="ar" />
<locale android:name="cs" />
<locale android:name="ru" />
</locale-config> </locale-config>
@@ -4,7 +4,6 @@ import dagger.Module
import dagger.Provides import dagger.Provides
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent import dagger.hilt.components.SingletonComponent
import de.jeanlucmakiola.agendula.data.sync.ManualSync
import de.jeanlucmakiola.agendula.data.sync.RemoteLists import de.jeanlucmakiola.agendula.data.sync.RemoteLists
import de.jeanlucmakiola.agendula.data.sync.RemoteLists.Outcome import de.jeanlucmakiola.agendula.data.sync.RemoteLists.Outcome
import de.jeanlucmakiola.agendula.data.tasks.room.AccountEntity import de.jeanlucmakiola.agendula.data.tasks.room.AccountEntity
@@ -27,9 +26,6 @@ object SyncModule {
@Provides @Provides
fun provideRemoteLists(): RemoteLists = NoRemoteLists fun provideRemoteLists(): RemoteLists = NoRemoteLists
@Provides
fun provideManualSync(): ManualSync = ManualSync.None
} }
private object NoRemoteLists : RemoteLists { private object NoRemoteLists : RemoteLists {
@@ -1,31 +0,0 @@
package de.jeanlucmakiola.agendula.data.appname
import android.content.pm.PackageManager
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test
class LauncherNameManagerTest {
@Test
fun `an enabled Tasks alias reads as Tasks`() {
assertThat(launcherNameFor(PackageManager.COMPONENT_ENABLED_STATE_ENABLED)).isEqualTo(LauncherName.TASKS)
}
@Test
fun `a default or disabled Tasks alias reads as Agendula`() {
assertThat(launcherNameFor(PackageManager.COMPONENT_ENABLED_STATE_DEFAULT)).isEqualTo(LauncherName.AGENDULA)
assertThat(launcherNameFor(PackageManager.COMPONENT_ENABLED_STATE_DISABLED)).isEqualTo(LauncherName.AGENDULA)
}
@Test
fun `switching always enables the target before disabling the other`() {
assertThat(aliasWritePlan(LauncherName.TASKS)).containsExactly(
AliasStateChange(LauncherAlias.TASKS, enabled = true),
AliasStateChange(LauncherAlias.DEFAULT, enabled = false),
).inOrder()
assertThat(aliasWritePlan(LauncherName.AGENDULA)).containsExactly(
AliasStateChange(LauncherAlias.DEFAULT, enabled = true),
AliasStateChange(LauncherAlias.TASKS, enabled = false),
).inOrder()
}
}
@@ -1,22 +0,0 @@
package de.jeanlucmakiola.agendula.data.reminders
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test
class ExpandedTextTest {
@Test
fun `no description means no expanded body`() {
assertThat(expandedText("Due today", null)).isNull()
}
@Test
fun `description alone when there is no due line`() {
assertThat(expandedText(null, "Buy oat milk")).isEqualTo("Buy oat milk")
}
@Test
fun `due line comes first, separated by a blank line`() {
assertThat(expandedText("Due today", "Buy oat milk")).isEqualTo("Due today\n\nBuy oat milk")
}
}
@@ -1,44 +0,0 @@
package de.jeanlucmakiola.agendula.data.reminders
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.agendula.data.tasks.TasksDataSource
import de.jeanlucmakiola.agendula.domain.Task
import de.jeanlucmakiola.agendula.domain.TaskStatus
import de.jeanlucmakiola.agendula.domain.testTask
import org.junit.jupiter.api.Test
import java.lang.reflect.Proxy
import kotlin.time.Instant
class OccurrenceLookupTest {
private val yesterday = Instant.fromEpochMilliseconds(1_800_000_000_000)
private val today = Instant.fromEpochMilliseconds(1_800_000_000_000 + 86_400_000)
private val current = testTask(id = 10).copy(seriesId = 10, occurrenceStart = today)
/** Answers only the two lookups [occurrence] makes. */
private fun source(byAnchor: (Instant?) -> Task?, list: List<Task> = emptyList()): TasksDataSource =
Proxy.newProxyInstance(TasksDataSource::class.java.classLoader, arrayOf(TasksDataSource::class.java)) { _, method, args ->
when {
method.name == "task" && args?.size == 2 -> byAnchor(args[1] as Instant?)
method.name == "tasks" -> list
else -> throw UnsupportedOperationException(method.name)
}
} as TasksDataSource
@Test
fun `a ticked occurrence is found although it is a row of its own`() {
val ticked = testTask(id = 57, status = TaskStatus.COMPLETED, completedAt = today)
.copy(seriesId = 10, occurrenceStart = yesterday)
val found = source({ if (it == yesterday) ticked else current }).occurrence(10, yesterday.toEpochMilliseconds())
assertThat(found).isEqualTo(ticked)
assertThat(reminderIsStale(yesterday.toEpochMilliseconds(), found, missingMeansGone = true)).isTrue()
}
@Test
fun `an exception row the anchored lookup misses is found in the list`() {
val exception = testTask(id = 99, status = TaskStatus.COMPLETED, completedAt = today)
.copy(seriesId = 10, occurrenceStart = yesterday)
val found = source({ current }, listOf(current, exception)).occurrence(10, yesterday.toEpochMilliseconds())
assertThat(found).isEqualTo(exception)
}
}
@@ -44,14 +44,6 @@ class ReminderDiffTest {
assertThat(diff.keep).containsExactly(a) assertThat(diff.keep).containsExactly(a)
} }
@Test
fun `a reopened task within the grace does not ring again`() {
// Ticked, so the store dropped it; undone, so it is wanted again.
val diff = ReminderDiff.of(previous = emptySet(), desired = setOf(a), fired = setOf(a)) { false }
assertThat(diff.arm).isEmpty()
assertThat(diff.keep).containsExactly(a)
}
@Test @Test
fun `store entries round-trip, and junk is skipped`() { fun `store entries round-trip, and junk is skipped`() {
val encoded = ScheduledReminderStore.encode(setOf(a, b)) val encoded = ScheduledReminderStore.encode(setOf(a, b))
Loaded 100 of 133 files, more files were not shown because too many files have changed in this diff. Show more