Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -226,7 +226,10 @@ class TimeEntryRepository @Inject constructor(
queued + conflicts.filterNot { it.id in queuedIds }.map { conflict ->
SyncOperation(
entryId = conflict.id,
type = OutboxOpType.UPDATE,
// The outbox operation is removed when a conflict is captured, but a pending
// local delete remains encoded on the Room row. Preserve that intent so Sync &
// recovery does not mislabel a guarded deletion as an ordinary edit.
type = if (conflict.pendingDelete) OutboxOpType.DELETE else OutboxOpType.UPDATE,
status = EntrySyncStatus.CONFLICT,
attemptCount = 0,
error = null,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -670,7 +670,17 @@ private fun ConflictIssueCard(issue: InboxIssue, projectsById: Map<String, Proje
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End),
) {
TextButton(onClick = onKeepTheirs) { Text(stringResource(R.string.inbox_conflict_keep_theirs)) }
FilledTonalButton(onClick = onKeepMine) { Text(stringResource(R.string.inbox_conflict_keep_mine)) }
FilledTonalButton(onClick = onKeepMine) {
Text(
stringResource(
if (issue.conflictLocalDeleted) {
R.string.inbox_conflict_confirm_delete
} else {
R.string.inbox_conflict_keep_mine
},
),
)
}
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -75,22 +75,60 @@ internal const val SYNC_STATUS_REVEAL_DELAY_MS = 3_000L
/** Which slice of history the user is currently looking at. */
internal enum class HistoryWindowMode { RECENT, PAGINATED }

internal enum class HistoryMembershipChange { COMPLETED_ENTRY_PRESENT, ENTRY_ABSENT }

internal fun resolvedHistoryMembershipChangeIds(changes: Map<String, HistoryMembershipChange>, collected: List<TimeEntry>): Set<String> {
val collectedById = collected.associateBy { it.id }
return changes.mapNotNullTo(mutableSetOf()) { (entryId, change) ->
val collectedEntry = collectedById[entryId]
when (change) {
HistoryMembershipChange.COMPLETED_ENTRY_PRESENT -> entryId.takeIf {
collectedEntry != null && isCompletedTimeEntry(collectedEntry)
}
HistoryMembershipChange.ENTRY_ABSENT -> entryId.takeIf { collectedEntry == null }
}
}
}

/**
* Single source of truth for how a Room emission from the recent-window collector combines with
* the list currently on screen.
*
* In [HistoryWindowMode.RECENT] the collector owns the list and replaces it wholesale, so live
* edits and the active-entry poll stay fresh. Once the user pages or jumps to an off-window slice
* ([HistoryWindowMode.PAGINATED]) the network-fetched window is authoritative: its order and
* membership are preserved (so scroll position survives a poll emission) while any fresher copy of
* a still-visible entry carried by the recent collector is overlaid in place.
* ([HistoryWindowMode.PAGINATED]) the network-fetched window normally preserves its membership so
* scroll position survives a poll emission. Entries mutated locally are the exception: Room is
* authoritative for whether those entries are present, even while the paginated window is shown.
*/
internal object HistoryWindow {
fun merge(mode: HistoryWindowMode, displayed: List<TimeEntry>, collected: List<TimeEntry>): List<TimeEntry> = when (mode) {
fun merge(
mode: HistoryWindowMode,
displayed: List<TimeEntry>,
collected: List<TimeEntry>,
locallyMutatedEntryIds: Set<String> = emptySet(),
): List<TimeEntry> = when (mode) {
HistoryWindowMode.RECENT -> collected
HistoryWindowMode.PAGINATED -> {
val collectedById = collected.associateBy { it.id }
displayed.map { collectedById[it.id] ?: it }
val refreshed = displayed.mapNotNull { displayedEntry ->
collectedById[displayedEntry.id]
?: displayedEntry.takeUnless { it.id in locallyMutatedEntryIds }
}
val displayedIds = displayed.mapTo(mutableSetOf()) { it.id }
val completedAdditions = collected.filter {
it.id in locallyMutatedEntryIds && it.id !in displayedIds && isCompletedTimeEntry(it)
}

completedAdditions.fold(refreshed) { entries, addition ->
val insertionIndex = entries.indexOfFirst { it.start < addition.start }
if (insertionIndex == -1) {
entries + addition
} else {
entries.toMutableList().apply {
add(insertionIndex, addition)
}
}
}
}
}
}
Expand Down Expand Up @@ -294,6 +332,7 @@ class TrackingViewModel @Inject constructor(
private var historyOffset = 0
private var historyWindowStartOffset = 0
private var historyWindowMode = HistoryWindowMode.RECENT
private val pendingHistoryMembershipChanges = mutableMapOf<String, HistoryMembershipChange>()
private var isInitialized = false

/**
Expand Down Expand Up @@ -480,6 +519,7 @@ class TrackingViewModel @Inject constructor(
historyWindowStartOffset = 0
historyOffset = 0
historyWindowMode = HistoryWindowMode.RECENT
pendingHistoryMembershipChanges.clear()
clearActivePollOverride()
dataCollectorJob = viewModelScope.launch {
combine(
Expand Down Expand Up @@ -525,7 +565,17 @@ class TrackingViewModel @Inject constructor(
// paging offset) while the recent slice is on screen. Once the user has paged or
// jumped, loadMore/jump own the window and offset; here we merely refresh visible
// entries in place so a poll emission cannot wipe the window or reset scroll.
val displayedEntries = HistoryWindow.merge(mode, currentState.timeEntries, data.entries)
val resolvedMembershipChanges = resolvedHistoryMembershipChangeIds(
pendingHistoryMembershipChanges,
data.entries,
)
val displayedEntries = HistoryWindow.merge(
mode = mode,
displayed = currentState.timeEntries,
collected = data.entries,
locallyMutatedEntryIds = resolvedMembershipChanges,
)
resolvedMembershipChanges.forEach(pendingHistoryMembershipChanges::remove)
if (mode == HistoryWindowMode.RECENT) {
historyOffset = data.entries.size
}
Expand Down Expand Up @@ -1532,6 +1582,7 @@ class TrackingViewModel @Inject constructor(
// Active polling can complete between the local STOP transaction and the outbox observer
// emission. Suppress that exact server id synchronously while the STOP is being queued.
locallyStoppingEntryIds += currentEntry.id
pendingHistoryMembershipChanges[currentEntry.id] = HistoryMembershipChange.COMPLETED_ENTRY_PRESENT
clearActivePollOverride()

viewModelScope.launch {
Expand Down Expand Up @@ -1899,6 +1950,7 @@ class TrackingViewModel @Inject constructor(

// Optimistic local-only soft-delete; the collector removes it from the list. No outbox op
// exists yet, so there is nothing here for the sync worker to act on.
pendingHistoryMembershipChanges[entry.id] = HistoryMembershipChange.ENTRY_ABSENT
timeEntryRepository.softDeleteLocal(entry)

pendingDeleteCommitJobs.remove(entry.id)?.cancel()
Expand All @@ -1920,7 +1972,9 @@ class TrackingViewModel @Inject constructor(
// this guarantees nothing was ever enqueued to the outbox for the repository undo path
// to race against.
pendingDeleteCommitJobs.remove(entry.id)?.cancel()
pendingHistoryMembershipChanges[entry.id] = HistoryMembershipChange.COMPLETED_ENTRY_PRESENT
if (!timeEntryRepository.undoDelete(entry, historyMemberId)) {
pendingHistoryMembershipChanges.remove(entry.id)
_uiState.value = _uiState.value.copy(error = context.getString(R.string.undo_delete_too_late))
} else {
syncTrigger.requestSync()
Expand Down
2 changes: 1 addition & 1 deletion app/src/main/res/values-ja/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@
<string name="search_history">履歴を検索</string>
<string name="needs_categorization">分類が必要</string>
<string name="clear_filters">フィルターを解除</string>
<string name="entry_deleted">エントリーを削除しました</string>
<string name="entry_deleted">削除を保留中</string>
<string name="undo">元に戻す</string>
<string name="undo_delete_too_late">このエントリーはすでにサーバーから削除されているため、自動的に復元できません。</string>
<!-- App Name -->
Expand Down
1 change: 1 addition & 0 deletions app/src/main/res/values-ja/strings_inbox.xml
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@
<string name="inbox_conflict_empty_value">—</string>
<string name="inbox_conflict_version_summary">%1$s · %2$s · %3$s</string>
<string name="inbox_conflict_keep_mine">自分の版を残す</string>
<string name="inbox_conflict_confirm_delete">項目を削除</string>
<string name="inbox_conflict_keep_theirs">サーバー版を残す</string>

<string name="inbox_action_dismiss">閉じる</string>
Expand Down
4 changes: 2 additions & 2 deletions app/src/main/res/values-ja/strings_sync.xml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
<string name="sync_status_card_title">同期状態</string>
<string name="sync_center_title">同期と復旧</string>
<string name="sync_center_open">同期の詳細を表示</string>
<string name="sync_center_description">サーバーへの送信を待っている変更を確認できます。失敗した変更を再試行するか、送信しない変更を破棄できます。</string>
<string name="sync_center_description">サーバーへの送信を待っている変更を確認できます。失敗した変更はここで再試行できます。競合はレビューで解決するまで安全に保持されます。</string>
<string name="sync_center_navigate_back">戻る</string>

<!-- 最新状況 -->
Expand Down Expand Up @@ -44,7 +44,7 @@
<!-- 失敗した変更 -->
<string name="sync_failures_section_title">同期に失敗しました</string>
<string name="sync_conflicts_section_title">確認が必要です</string>
<string name="sync_conflict_item">この項目は別のデバイスで変更されたため、確認が必要です。</string>
<string name="sync_conflict_item">この項目は別のデバイスで変更されました。レビューを開いて、どちらを反映するか選択してください。</string>
<string name="sync_retry_all">すべて再試行</string>
<string name="sync_retry">再試行</string>
<string name="sync_discard">破棄</string>
Expand Down
2 changes: 1 addition & 1 deletion app/src/main/res/values-nl/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@
<string name="search_history">Geschiedenis doorzoeken</string>
<string name="needs_categorization">Moet worden gecategoriseerd</string>
<string name="clear_filters">Filters wissen</string>
<string name="entry_deleted">Boeking verwijderd</string>
<string name="entry_deleted">Verwijdering in wachtrij</string>
<string name="undo">Ongedaan maken</string>
<string name="undo_delete_too_late">Deze boeking is al van de server verwijderd en kan niet automatisch worden hersteld.</string>
<!-- App Name -->
Expand Down
1 change: 1 addition & 0 deletions app/src/main/res/values-nl/strings_inbox.xml
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@
<string name="inbox_conflict_empty_value">—</string>
<string name="inbox_conflict_version_summary">%1$s · %2$s · %3$s</string>
<string name="inbox_conflict_keep_mine">Mijn versie houden</string>
<string name="inbox_conflict_confirm_delete">Item verwijderen</string>
<string name="inbox_conflict_keep_theirs">Serverversie houden</string>

<string name="inbox_action_dismiss">Negeren</string>
Expand Down
4 changes: 2 additions & 2 deletions app/src/main/res/values-nl/strings_sync.xml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
<string name="sync_status_card_title">Synchronisatiestatus</string>
<string name="sync_center_title">Synchronisatie en herstel</string>
<string name="sync_center_open">Synchronisatiedetails bekijken</string>
<string name="sync_center_description">Bekijk welke wijzigingen nog op de server wachten. Je kunt mislukte wijzigingen opnieuw proberen of wijzigingen verwijderen die je niet meer wilt verzenden.</string>
<string name="sync_center_description">Bekijk welke wijzigingen nog op de server wachten. Probeer mislukte wijzigingen hier opnieuw. Conflicten blijven veilig bewaard totdat je ze oplost onder Nakijken.</string>
<string name="sync_center_navigate_back">Terug</string>

<!-- Actualiteit -->
Expand Down Expand Up @@ -48,7 +48,7 @@
<!-- Mislukkingen -->
<string name="sync_failures_section_title">Synchronisatie mislukt</string>
<string name="sync_conflicts_section_title">Vraagt om je aandacht</string>
<string name="sync_conflict_item">Dit item is op een ander apparaat gewijzigd en vraagt om je aandacht.</string>
<string name="sync_conflict_item">Dit item is op een ander apparaat gewijzigd. Open Nakijken om te kiezen wat er moet gebeuren.</string>
<string name="sync_retry_all">Alles opnieuw proberen</string>
<string name="sync_retry">Opnieuw</string>
<string name="sync_discard">Verwijderen</string>
Expand Down
2 changes: 1 addition & 1 deletion app/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -205,7 +205,7 @@
<string name="no_time_entries">No time entries yet</string>
<string name="edit">Edit</string>
<string name="delete">Delete</string>
<string name="entry_deleted">Entry deleted</string>
<string name="entry_deleted">Deletion queued</string>
<string name="undo">Undo</string>
<string name="undo_delete_too_late">This entry was already deleted on the server and can no longer be restored automatically.</string>
<string name="no_description">No description</string>
Expand Down
1 change: 1 addition & 0 deletions app/src/main/res/values/strings_inbox.xml
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@
<string name="inbox_conflict_empty_value">—</string>
<string name="inbox_conflict_version_summary">%1$s · %2$s · %3$s</string>
<string name="inbox_conflict_keep_mine">Keep mine</string>
<string name="inbox_conflict_confirm_delete">Delete entry</string>
<string name="inbox_conflict_keep_theirs">Keep server</string>

<string name="inbox_action_dismiss">Dismiss</string>
Expand Down
4 changes: 2 additions & 2 deletions app/src/main/res/values/strings_sync.xml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
<string name="sync_status_card_title">Sync status</string>
<string name="sync_center_title">Sync &amp; recovery</string>
<string name="sync_center_open">View sync details</string>
<string name="sync_center_description">See which changes are waiting for the server. You can retry failed changes or discard ones you no longer want to send.</string>
<string name="sync_center_description">See which changes are waiting for the server. Retry failed changes here. Conflicts stay safe until you resolve them in Review.</string>
<string name="sync_center_navigate_back">Back</string>

<!-- Freshness section -->
Expand Down Expand Up @@ -48,7 +48,7 @@
<!-- Failures section -->
<string name="sync_failures_section_title">Failed to sync</string>
<string name="sync_conflicts_section_title">Need your review</string>
<string name="sync_conflict_item">This entry changed on another device and needs your review.</string>
<string name="sync_conflict_item">This entry changed on another device. Open Review to choose what should happen.</string>
<string name="sync_retry_all">Retry all</string>
<string name="sync_retry">Retry</string>
<string name="sync_discard">Discard</string>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -774,6 +774,60 @@ class TimeEntryRepositoryWriteTest {
assertEquals("server", Json.decodeFromString<ConflictSnapshot>(op.baseSnapshotJson!!).description)
}

@Test fun sync_operations_distinguish_delete_conflicts_from_edit_conflicts() = runTest {
val server = TimeEntry(
id = "delete-conflict",
userId = "u",
organizationId = "org1",
start = "2026-07-07T08:00:00Z",
end = "2026-07-07T09:00:00Z",
description = "server",
)
val editConflict = server.copy(id = "edit-conflict", description = "mine")
db.timeEntryDao().upsert(
server.toEntity(2L, SyncState.CONFLICT, pendingDelete = true).copy(
conflictServerJson = testJson.encodeToString(server.copy(description = "changed elsewhere")),
),
)
db.timeEntryDao().upsert(
editConflict.toEntity(2L, SyncState.CONFLICT).copy(
conflictServerJson = testJson.encodeToString(editConflict.copy(description = "changed elsewhere")),
),
)

val operations = repo.observeSyncOperations("org1").first().associateBy { it.entryId }

assertEquals(OutboxOpType.DELETE, operations.getValue("delete-conflict").type)
assertEquals(OutboxOpType.UPDATE, operations.getValue("edit-conflict").type)
assertTrue(operations.values.all { it.status == TimeEntryRepository.EntrySyncStatus.CONFLICT })
}

@Test fun confirming_a_local_delete_conflict_requeues_the_delete() = runTest {
val server = TimeEntry(
id = "delete-conflict",
userId = "u",
organizationId = "org1",
start = "2026-07-07T08:00:00Z",
end = "2026-07-07T09:00:00Z",
description = "changed elsewhere",
)
val local = server.copy(description = "local baseline")
db.timeEntryDao().upsert(
local.toEntity(2L, SyncState.CONFLICT, pendingDelete = true).copy(
conflictServerJson = testJson.encodeToString(server),
),
)

assertTrue(repo.resolveKeepMine(local.id, "member"))

val stored = db.timeEntryDao().getById(local.id)
val operation = db.outboxDao().peekAll().single()
assertEquals(SyncState.PENDING, stored?.syncState)
assertEquals(true, stored?.pendingDelete)
assertNull(stored?.conflictServerJson)
assertEquals(OutboxOpType.DELETE, operation.opType)
}

@Test fun keep_theirs_restores_server_copy_and_clears_conflict() = runTest {
val local = TimeEntry(
id = "server-1",
Expand Down
Loading
Loading