Keep the list order with tasks.org and OpenTasks (#136) (#143)
Beta — Codeberg pre-release / detect (push) Successful in 6s
Beta — Codeberg pre-release / beta (push) Skipped

This commit is contained in:
Jean-Luc Makiola committed 2026-11-09 18:30:33 +01:00
1 parent 109eda82b2
commit 2dfc676b8f
7 files changed
+219 -7

No files matched your search

@@ -55,14 +55,47 @@ class AndroidTasksDataSource @Inject constructor(
// --- reads ----------------------------------------------------------------
override fun taskLists(): List<TaskList> {
val uri = TasksContract.listsUri(authority())
val sort = "${Lists.ACCOUNT_NAME}, ${Lists.NAME}"
return resolver.query(uri, TaskProjections.LISTS, null, null, sort)?.use { c ->
val authority = authority()
val lists = resolver.query(TasksContract.listsUri(authority), TaskProjections.LISTS, null, null, null)?.use { c ->
val reader = CursorColumnReader(c)
buildList { while (c.moveToNext()) add(TaskMapper.taskList(reader)) }
} ?: emptyList()
buildList<TaskList> { while (c.moveToNext()) add(TaskMapper.taskList(reader)) }
} ?: return emptyList()
val order = synchronized(listOrderLock) {
val stored = storedListOrder(authority)
val known = lists.mapTo(HashSet()) { it.id }
// A list gone from the provider takes its position along, as its id may be
// reused — but not on an empty answer, which is as likely a provider mid-update.
if (lists.isNotEmpty() && stored.keys.any { it !in known }) {
stored.filterKeys { it in known }.also { storeListOrder(authority, it) }
} else {
stored
}
}
return ListOrder.sorted(lists, order)
}
/** The order Agendula keeps for [authority]'s lists. */
internal fun storedListOrder(authority: String = authority()): Map<Long, Int> =
ListOrder.decode(listOrder.getString(authority, null))
private val listOrder get() = context.getSharedPreferences(LIST_ORDER, Context.MODE_PRIVATE)
private fun storeListOrder(authority: String, order: Map<Long, Int>) {
listOrder.edit().putString(authority, ListOrder.encode(order)).commit()
}
/** Kept by Agendula, as the provider's lists table has no order of its own. */
override fun reorderLists(listIds: List<Long>) {
val authority = authority()
synchronized(listOrderLock) {
storeListOrder(authority, ListOrder.reordered(taskLists().map { it.id }, listIds))
}
// Wakes the observers on the lists without asking any sync adapter to upload: nothing changed in the provider.
runCatching { resolver.notifyChange(TasksContract.listsUri(authority), null, 0) }
}
private val listOrderLock = Any()
override fun tasks(query: TaskQuery): List<Task> {
val clauses = mutableListOf<String>()
val args = mutableListOf<String>()
@@ -787,6 +820,8 @@ class AndroidTasksDataSource @Inject constructor(
}
}
private const val LIST_ORDER = "provider_list_order"
/** An instance row of the task, or of an override standing in for one of its occurrences. */
private const val OCCURRENCE_OF = "${Instances.TASK_ID} = ? OR ${Tasks.ORIGINAL_INSTANCE_ID} = ?"
@@ -0,0 +1,36 @@
package de.jeanlucmakiola.agendula.data.tasks
import de.jeanlucmakiola.agendula.domain.TaskList
/**
* Agendula's own order for an external provider's lists, which the provider has
* no column for. Stored as `id=position` pairs. A list without one counts as
* position 0, as a new list does in Agendula's own store.
*/
object ListOrder {
fun decode(raw: String?): Map<Long, Int> = raw.orEmpty().split(',').mapNotNull { pair ->
val (id, position) = pair.split('=').takeIf { it.size == 2 } ?: return@mapNotNull null
id.toLongOrNull()?.let { key -> position.toIntOrNull()?.let { key to it } }
}.toMap()
fun encode(order: Map<Long, Int>): String = order.entries.joinToString(",") { "${it.key}=${it.value}" }
/**
* Positions for every list in [shown] — the lists in their current order —
* after one account's [listIds] were rearranged: those take each other's
* slots in their new order, and every other list keeps its own.
*/
fun reordered(shown: List<Long>, listIds: List<Long>): Map<Long, Int> {
val moved = listIds.iterator()
val moving = listIds.toSet()
val result = shown.map { if (it in moving && moved.hasNext()) moved.next() else it }.toMutableList()
moved.forEachRemaining { if (it !in result) result += it }
return result.withIndex().associate { it.value to it.index }
}
fun sorted(lists: List<TaskList>, order: Map<Long, Int>): List<TaskList> = lists.sortedWith(
compareBy<TaskList>({ it.accountName.lowercase() }, { order[it.id] ?: 0 }, { it.name.lowercase() })
.thenBy { it.name },
)
}
@@ -18,7 +18,7 @@ interface TaskListDao {
"""
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
ORDER BY a.display_name COLLATE NOCASE, l.sort_order, l.name COLLATE NOCASE, l.name
"""
)
fun lists(): List<TaskListRow>
@@ -32,6 +32,10 @@ interface TaskListDao {
)
fun list(listId: Long): TaskListRow?
/** The highest position among device-only lists, -1 when there are none. */
@Query("SELECT COALESCE(MAX(sort_order), -1) FROM task_lists WHERE account_id IS NULL")
fun lastLocalSortOrder(): Int
@Query("SELECT * FROM task_lists WHERE id = :listId")
fun entity(listId: Long): TaskListEntity?
@@ -159,7 +159,9 @@ class ExternalImport @Inject constructor(
val taskDao = database.tasks()
val alarmDao = database.alarms()
val listIds = snapshot.lists.associate { list ->
// After the device-only lists already there, which the copies join.
val after = listDao.lastLocalSortOrder() + 1
val listIds = snapshot.lists.withIndex().associate { (position, list) ->
list.id to listDao.insert(
TaskListEntity(
name = list.name,
@@ -168,6 +170,7 @@ class ExternalImport @Inject constructor(
isVisible = list.isVisible,
isSynced = false,
owner = list.owner,
sortOrder = after + position,
),
)
}