Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aabc24a472 | ||
|
|
a8bc3f0084 |
@@ -0,0 +1,553 @@
|
||||
{
|
||||
"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')"
|
||||
]
|
||||
}
|
||||
}
|
||||
+15
@@ -147,6 +147,21 @@ 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 {
|
||||
const val TEST_DB = "migration-test.db"
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import dagger.hilt.components.SingletonComponent
|
||||
import dagger.multibindings.IntoSet
|
||||
import de.jeanlucmakiola.agendula.data.sync.AccountCreator
|
||||
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.LoginFlowRecord
|
||||
import de.jeanlucmakiola.agendula.data.sync.OkHttpCalDavGateway
|
||||
@@ -79,7 +80,7 @@ abstract class SyncBindModule {
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
abstract fun bindSyncRequests(impl: SyncTrigger): SyncRequests
|
||||
abstract fun bindSyncRequests(impl: AccountSyncRequests): SyncRequests
|
||||
|
||||
@Binds
|
||||
@IntoSet
|
||||
|
||||
@@ -97,6 +97,11 @@ class AccountRepository @Inject constructor(
|
||||
/** The accounts, observed, so a sync landing updates a screen that is open. */
|
||||
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. */
|
||||
fun observeSyncedLists(): Flow<List<TaskListEntity>> = database.taskLists().observeSynced()
|
||||
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -35,11 +35,7 @@ import kotlin.time.Duration.Companion.seconds
|
||||
@Singleton
|
||||
class SyncTrigger @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
) : SyncRequests {
|
||||
|
||||
override fun syncNow(accountName: String) {
|
||||
enqueue(accountName)
|
||||
}
|
||||
) {
|
||||
|
||||
/**
|
||||
* Starts a sync now.
|
||||
|
||||
@@ -15,6 +15,7 @@ import androidx.compose.material.icons.rounded.Check
|
||||
import androidx.compose.material.icons.rounded.Bolt
|
||||
import androidx.compose.material.icons.rounded.CloudSync
|
||||
import androidx.compose.material.icons.rounded.DeleteOutline
|
||||
import androidx.compose.material.icons.rounded.Edit
|
||||
import androidx.compose.material.icons.rounded.History
|
||||
import androidx.compose.material.icons.rounded.HistoryToggleOff
|
||||
import androidx.compose.material.icons.rounded.Inventory2
|
||||
@@ -26,6 +27,7 @@ import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
@@ -46,9 +48,11 @@ import de.jeanlucmakiola.agendula.data.sync.DiscardedEdit
|
||||
import de.jeanlucmakiola.agendula.data.sync.SyncNotice
|
||||
import de.jeanlucmakiola.agendula.data.tasks.room.AccountEntity
|
||||
import de.jeanlucmakiola.agendula.ui.accounts.add.message
|
||||
import de.jeanlucmakiola.agendula.ui.common.NameField
|
||||
import de.jeanlucmakiola.agendula.ui.common.OnResume
|
||||
import de.jeanlucmakiola.floret.components.CollapsingScaffold
|
||||
import de.jeanlucmakiola.floret.components.FullScreenPicker
|
||||
import de.jeanlucmakiola.floret.components.GroupedHint
|
||||
import de.jeanlucmakiola.floret.components.GroupedListInset
|
||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||
import de.jeanlucmakiola.floret.components.Position
|
||||
@@ -90,12 +94,34 @@ internal fun AccountDetailScreen(
|
||||
// The confirmation sheet. The removal it starts outlives this screen, and is
|
||||
// shown on the accounts list rather than here.
|
||||
var confirming by remember { mutableStateOf(false) }
|
||||
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) {
|
||||
AccountHero(identity)
|
||||
Spacer(Modifier.height(24.dp))
|
||||
|
||||
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 ->
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.accounts_last_sync),
|
||||
@@ -480,3 +506,30 @@ private fun errorTitle(text: String): AnnotatedString {
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,6 +44,14 @@ internal data class AccountIdentity(
|
||||
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 {
|
||||
val url = principalUrl?.toHttpUrlOrNull()
|
||||
val provider = url?.let { CalDavProvider.forPrincipal(it) }
|
||||
@@ -53,7 +61,7 @@ internal fun AccountEntity.identity(): AccountIdentity {
|
||||
provider = provider,
|
||||
// The fallback is the stored name: an account with no principal URL
|
||||
// never got past discovery, so there is nothing better to call it.
|
||||
title = if (hosted) provider.label else host ?: displayName,
|
||||
title = label ?: defaultTitle(),
|
||||
secondary = if (hosted) host else provider?.label,
|
||||
user = username?.takeIf { it.isNotBlank() },
|
||||
)
|
||||
|
||||
@@ -248,6 +248,10 @@ class AccountsViewModel @Inject constructor(
|
||||
syncTrigger.enqueue(account.displayName, expedited = true)
|
||||
}
|
||||
|
||||
fun rename(account: AccountEntity, label: String) {
|
||||
viewModelScope.launch { repository.setLabel(account.id, label) }
|
||||
}
|
||||
|
||||
/** The user has read what the last sync changed. */
|
||||
fun dismissNotices(account: AccountEntity) {
|
||||
viewModelScope.launch { notices.dismiss(account.id) }
|
||||
|
||||
@@ -1386,10 +1386,14 @@ class AddAccountViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
// The port is part of the name: two servers on one host are two accounts (#105).
|
||||
private fun accountName(): String =
|
||||
username.takeIf { it.isNotBlank() }?.let { "$it@${serverRoot?.host.orEmpty()}" }
|
||||
username.takeIf { it.isNotBlank() }?.let { "$it@${serverRoot?.authority().orEmpty()}" }
|
||||
?: typedInput
|
||||
|
||||
private fun HttpUrl.authority(): String =
|
||||
if (port == HttpUrl.defaultPort(scheme)) host else "$host:$port"
|
||||
|
||||
internal companion object {
|
||||
const val POLL_INTERVAL_MILLIS = 2_000L
|
||||
|
||||
|
||||
@@ -89,7 +89,7 @@ object DataProvideModule {
|
||||
// backed-up `.db` does not. The backup rules carry all three files and
|
||||
// the app checkpoints on ON_STOP.
|
||||
.setJournalMode(RoomDatabase.JournalMode.WRITE_AHEAD_LOGGING)
|
||||
.addMigrations(TasksDatabase.MIGRATION_1_2, TasksDatabase.MIGRATION_2_3)
|
||||
.addMigrations(TasksDatabase.MIGRATION_1_2, TasksDatabase.MIGRATION_2_3, TasksDatabase.MIGRATION_3_4)
|
||||
.build()
|
||||
|
||||
/**
|
||||
|
||||
@@ -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. */
|
||||
fun interface SyncRequests {
|
||||
fun syncNow(accountName: String)
|
||||
suspend fun syncNow(accountId: Long)
|
||||
}
|
||||
|
||||
@@ -37,6 +37,9 @@ interface AccountDao {
|
||||
@Query("UPDATE accounts SET last_sync_at = :at, last_sync_error = :error WHERE id = :accountId")
|
||||
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")
|
||||
fun delete(accountId: Long): Int
|
||||
}
|
||||
|
||||
@@ -26,6 +26,8 @@ data class AccountEntity(
|
||||
@ColumnInfo(name = "username") val username: String? = null,
|
||||
@ColumnInfo(name = "last_sync_at") val lastSyncAt: Instant? = 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,6 +11,7 @@ import androidx.room.Embedded
|
||||
data class TaskListRow(
|
||||
@Embedded val list: TaskListEntity,
|
||||
@ColumnInfo(name = "account_display_name") val accountDisplayName: String?,
|
||||
@ColumnInfo(name = "account_label") val accountLabel: String? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -23,5 +24,6 @@ data class TaskRow(
|
||||
@ColumnInfo(name = "list_name") val listName: String,
|
||||
@ColumnInfo(name = "list_color") val listColor: Int,
|
||||
@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,
|
||||
)
|
||||
|
||||
@@ -20,7 +20,7 @@ object RoomTaskMapper {
|
||||
color = row.list.color,
|
||||
// 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.
|
||||
accountName = row.accountDisplayName ?: LocalAccount.NAME,
|
||||
accountName = row.accountLabel ?: row.accountDisplayName ?: LocalAccount.NAME,
|
||||
accountType = if (row.list.accountId == null) LocalAccount.TYPE else CALDAV_ACCOUNT_TYPE,
|
||||
isSynced = row.list.isSynced,
|
||||
isVisible = row.list.isVisible,
|
||||
@@ -61,7 +61,7 @@ object RoomTaskMapper {
|
||||
listColor = row.listColor,
|
||||
taskColor = row.task.color,
|
||||
listName = row.listName,
|
||||
accountName = row.accountDisplayName ?: LocalAccount.NAME,
|
||||
accountName = row.accountLabel ?: row.accountDisplayName ?: LocalAccount.NAME,
|
||||
parentId = row.task.parentId,
|
||||
isRecurring = row.task.isRecurring,
|
||||
occurrenceStart = occurrenceStart,
|
||||
|
||||
@@ -29,7 +29,7 @@ interface TaskDao {
|
||||
@Query(
|
||||
"""
|
||||
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.display_name AS account_display_name, a.label AS account_label
|
||||
FROM tasks t
|
||||
JOIN task_lists l ON l.id = t.list_id
|
||||
LEFT JOIN accounts a ON a.id = l.account_id
|
||||
@@ -44,7 +44,7 @@ interface TaskDao {
|
||||
@Query(
|
||||
"""
|
||||
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.display_name AS account_display_name, a.label AS account_label
|
||||
FROM tasks t
|
||||
JOIN task_lists l ON l.id = t.list_id
|
||||
LEFT JOIN accounts a ON a.id = l.account_id
|
||||
@@ -56,7 +56,7 @@ interface TaskDao {
|
||||
@Query(
|
||||
"""
|
||||
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.display_name AS account_display_name, a.label AS account_label
|
||||
FROM tasks t
|
||||
JOIN task_lists l ON l.id = t.list_id
|
||||
LEFT JOIN accounts a ON a.id = l.account_id
|
||||
|
||||
@@ -16,7 +16,7 @@ interface TaskListDao {
|
||||
|
||||
@Query(
|
||||
"""
|
||||
SELECT l.*, a.display_name AS account_display_name
|
||||
SELECT l.*, a.display_name AS account_display_name, a.label AS account_label
|
||||
FROM task_lists l LEFT JOIN accounts a ON a.id = l.account_id
|
||||
ORDER BY a.display_name, l.sort_order, l.name
|
||||
"""
|
||||
@@ -25,7 +25,7 @@ interface TaskListDao {
|
||||
|
||||
@Query(
|
||||
"""
|
||||
SELECT l.*, a.display_name AS account_display_name
|
||||
SELECT l.*, a.display_name AS account_display_name, a.label AS account_label
|
||||
FROM task_lists l LEFT JOIN accounts a ON a.id = l.account_id
|
||||
WHERE l.id = :listId
|
||||
"""
|
||||
|
||||
@@ -22,7 +22,7 @@ import kotlin.time.Instant
|
||||
TaskEntity::class,
|
||||
TaskAlarmEntity::class,
|
||||
],
|
||||
version = 3,
|
||||
version = 4,
|
||||
exportSchema = true,
|
||||
)
|
||||
@TypeConverters(Converters::class)
|
||||
@@ -173,5 +173,12 @@ 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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -161,7 +161,7 @@ class ImportViewModel @Inject constructor(
|
||||
val result = importer.import(ready.parsed, target)
|
||||
(chosen as? ImportChoice.Existing)?.list
|
||||
?.takeIf { it.accountId != null && result.imported > 0 }
|
||||
?.let { runCatching { syncRequests.syncNow(it.accountName) } }
|
||||
?.let { runCatching { syncRequests.syncNow(it.accountId!!) } }
|
||||
ImportStep.Done(
|
||||
listName = listName,
|
||||
imported = result.imported,
|
||||
|
||||
@@ -681,6 +681,8 @@
|
||||
<string name="accounts_remove_wipe_body">Also deletes this account\u2019s lists and tasks from this device.</string>
|
||||
<string name="accounts_never_synced">Never synced</string>
|
||||
<string name="accounts_last_sync">Last sync</string>
|
||||
<string name="accounts_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_generic_provider">CalDAV account</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>
|
||||
|
||||
@@ -27,4 +27,16 @@ class RoomTaskMapperTest {
|
||||
|
||||
assertThat(RoomTaskMapper.taskList(list).acceptsWrites).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an account's label is shown in place of its stored name`() {
|
||||
val list = TaskListRow(
|
||||
list = TaskListEntity(id = 2, name = "Work", color = 0, accountId = 1),
|
||||
accountDisplayName = "me@host",
|
||||
accountLabel = "Work server",
|
||||
)
|
||||
|
||||
assertThat(RoomTaskMapper.taskList(list).accountName).isEqualTo("Work server")
|
||||
assertThat(RoomTaskMapper.taskList(list.copy(accountLabel = null)).accountName).isEqualTo("me@host")
|
||||
}
|
||||
}
|
||||
|
||||
+13
-2
@@ -1307,6 +1307,15 @@ class AddAccountViewModelTest {
|
||||
assertThat(vm.state.value.fatal).isEqualTo(AddAccountMessage.ExternalStorage)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a server on its own port is named with it`() = runTest(dispatcher) {
|
||||
val vm = signedIn("https://cloud.example.com:5233/")
|
||||
vm.onSave()
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(creator.created).containsExactly("me@cloud.example.com:5233")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a completed add reports the account it created`() = runTest(dispatcher) {
|
||||
val vm = signedIn()
|
||||
@@ -1406,12 +1415,14 @@ class AddAccountViewModelTest {
|
||||
* submit, so going straight from the address to a `Found` leaves the
|
||||
* username and password empty.
|
||||
*/
|
||||
private fun kotlinx.coroutines.test.TestScope.signedIn(): AddAccountViewModel {
|
||||
private fun kotlinx.coroutines.test.TestScope.signedIn(
|
||||
address: String = "https://cloud.example.com/",
|
||||
): AddAccountViewModel {
|
||||
gateway.discoveryOutcomes += CalDavDiscovery.Outcome.NeedsAuthentication(emptyList())
|
||||
gateway.loginFlow = null
|
||||
|
||||
val vm = viewModel()
|
||||
vm.enterAddress("https://cloud.example.com/")
|
||||
vm.enterAddress(address)
|
||||
vm.onAddressSubmitted()
|
||||
advanceUntilIdle()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user