diff --git a/CHANGELOG.md b/CHANGELOG.md index cebf849..557ed4d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # CHANGELOG -## [2.6.53] - 05.09.2026 +## [2.6.54] - 05.09.2026 **New** diff --git a/app/build.gradle b/app/build.gradle index f12a310..c69a447 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -21,7 +21,7 @@ apply from: "$projectDir/gradle/libs-task.gradle" apply from: "$projectDir/gradle/changelog-task.gradle" -def appVersionCode = 53 +def appVersionCode = 54 def appVersionName = "2.6.${appVersionCode}" def gitCommitHashProvider = providers.exec { diff --git a/app/src/androidTest/java/com/pasich/mynotes/db/RoomSyncStoreTest.java b/app/src/androidTest/java/com/pasich/mynotes/db/RoomSyncStoreTest.java index fe8ee88..2132dcf 100644 --- a/app/src/androidTest/java/com/pasich/mynotes/db/RoomSyncStoreTest.java +++ b/app/src/androidTest/java/com/pasich/mynotes/db/RoomSyncStoreTest.java @@ -796,10 +796,21 @@ public void resolveConflict_dropsARowWhoseWinnerTheRecordHasAlreadyLeftBehind() // pre-selected winner is a version the record has moved past. Offered anyway, it was // applied on a tap; here the winner is a deletion. int noteId = seedNote("loser", "body", null); + // The alternative is the note exactly as this store serialises it, so the record's + // content genuinely equals the loser: a rule that only drops rows equal to neither + // offer keeps this one and applies the deletion. + SyncRecord local = onlyNote(store.buildSnapshot().requireSnapshot()); db.syncMetadataDao().touch(SyncMetadata.RECORD_TYPE_NOTE, noteId, 5_000L); com.pasich.mynotes.data.database.entities.SyncConflictEntity row = noteConflictRow( "11111111-1111-4111-8111-111111111111", "deleted-winner", 3_000L, 2_000L); + row.loserVersionId = local.getCanonicalPayloadHash(); + row.loserJson = + "{\"type\":\"note\",\"id\":\"11111111-1111-4111-8111-111111111111\"," + + "\"updatedAt\":\"1970-01-01T00:00:02Z\",\"deletedAt\":null," + + "\"payload\":" + + new com.google.gson.Gson().toJson(local.getPayload()) + + "}"; row.winnerJson = "{\"type\":\"note\",\"id\":\"11111111-1111-4111-8111-111111111111\"," + "\"updatedAt\":\"1970-01-01T00:00:03Z\",\"deletedAt\":\"1970-01-01T00:00:03Z\"," @@ -866,6 +877,52 @@ public void applySnapshot_doesNotRecordThePreferencesBaseUntilTheCommitSucceeded .isEqualTo(remote.getCanonicalPayloadHash()); } + @Test + public void recoveryRecordsTheBaseOfAnApplyThatDiedBeforeItsBookkeeping() throws Exception { + PreferencesAdapter adapter = new PreferencesAdapter(); + new RoomSyncStore(context, db, adapter.helper).readState(); + com.google.gson.Gson gson = new com.google.gson.Gson(); + SyncRecord remote = + SyncRecord.live( + SyncRecord.Type.PREFERENCES, + "00000000-0000-4000-8000-000000000000", + java.time.Instant.ofEpochMilli(2_000L), + gson.toJsonTree(preferencesWithTheme(3)).getAsJsonObject()); + // What the apply transaction leaves when the process dies right after it: the record + // timestamped at the remote version, its payload journaled against the live digest, the + // base not yet recorded. + String stagedJson = gson.toJson(preferencesWithTheme(3)); + String liveDigest = + sha256(gson.toJson(preferencesWithTheme(1)).getBytes(StandardCharsets.UTF_8)); + db.syncMetadataDao().setVersion(SyncMetadata.RECORD_TYPE_PREFERENCES, 0, 2_000L, null); + db.syncPendingPreferencesDao() + .upsert( + new com.pasich.mynotes.data.database.entities.SyncPendingPreferencesEntity( + 1, + stagedJson, + sha256(stagedJson.getBytes(StandardCharsets.UTF_8)), + liveDigest, + 2_000L, + false, + 0L, + "")); + + // A fresh store seeds, which is where recovery replays the journal. + new RoomSyncStore(context, db, adapter.helper).readState(); + + assertThat(adapter.committed.get().getThemeValue()).isEqualTo(3); + assertThat(db.syncPendingPreferencesDao().get()).isNull(); + // Replayed but left with the base naming the version before it, the next merge saw a + // local edit that nobody made. + assertThat( + db.syncMetadataDao() + .getByStableId( + SyncMetadata.RECORD_TYPE_PREFERENCES, + "00000000-0000-4000-8000-000000000000") + .syncedVersionId) + .isEqualTo(remote.getCanonicalPayloadHash()); + } + /** A stored note conflict whose winner is titled after its version id. */ private com.pasich.mynotes.data.database.entities.SyncConflictEntity noteConflictRow( String stableId, String winnerVersionId, long winnerUpdatedAt, long loserUpdatedAt) { diff --git a/app/src/main/java/com/pasich/mynotes/data/sync/RoomSyncStore.java b/app/src/main/java/com/pasich/mynotes/data/sync/RoomSyncStore.java index 64f4458..9954bdc 100644 --- a/app/src/main/java/com/pasich/mynotes/data/sync/RoomSyncStore.java +++ b/app/src/main/java/com/pasich/mynotes/data/sync/RoomSyncStore.java @@ -643,6 +643,7 @@ private void recoverPendingPreferences() throws IOException { // stale baseline, calls this a local edit and touches the record to now, which // lets an unchanged copy outrank a genuine edit made on another device. preferences.edit().putString(PREFERENCES_HASH, target).commit(); + recordRecoveredPreferencesBase(pending, backup); finishJournal(pending); return; case DISCARD_STALE: @@ -655,10 +656,43 @@ private void recoverPendingPreferences() throws IOException { default: // Replay was decided because the live values still match the baseline. commitPendingPreferences(backup, target, pending.baselineHash); + recordRecoveredPreferencesBase(pending, backup); finishJournal(pending); } } + /** + * Records, on recovery, the base the interrupted apply would have recorded after its commit. + * + *
The apply transaction stamps the record with the remote version's time and journals its
+ * payload; the base is recorded only once the commit has succeeded. Dying in between left the
+ * live settings at the remote version with the base still naming the one before it, so the next
+ * merge saw a local edit nobody made. The version id is rebuilt from the journal: the same
+ * timestamp and the same payload, serialised by the same class, hash to the same id. A journal
+ * written by a conflict resolution is left alone; a resolution re-times the record without
+ * touching its base.
+ */
+ private void recordRecoveredPreferencesBase(
+ @NonNull SyncPendingPreferencesEntity pending, @NonNull PreferencesBackup backup) {
+ if (pending.conflictId > 0 || pending.recordUpdatedAt <= 0L) return;
+ SyncMetadataEntity metadata =
+ database.syncMetadataDao()
+ .getByStableId(SyncMetadata.RECORD_TYPE_PREFERENCES, PREFERENCES_STABLE_ID);
+ if (metadata == null || metadata.updatedAt != pending.recordUpdatedAt) return;
+ String versionId =
+ SyncRecord.live(
+ SyncRecord.Type.PREFERENCES,
+ PREFERENCES_STABLE_ID,
+ Instant.ofEpochMilli(pending.recordUpdatedAt),
+ gson.toJsonTree(backup).getAsJsonObject())
+ .getCanonicalPayloadHash();
+ database.runInTransaction(
+ () ->
+ database.syncMetadataDao()
+ .setSyncedVersion(
+ metadata.recordType, metadata.localId, versionId));
+ }
+
/** Clears the journal, completing the conflict bookkeeping when it names one. */
private void finishJournal(@NonNull SyncPendingPreferencesEntity pending) {
if (pending.conflictId > 0) {
diff --git a/app/src/test/java/com/pasich/mynotes/data/sync/TwoPeerConvergenceTest.java b/app/src/test/java/com/pasich/mynotes/data/sync/TwoPeerConvergenceTest.java
index 47ac7b3..ef998e0 100644
--- a/app/src/test/java/com/pasich/mynotes/data/sync/TwoPeerConvergenceTest.java
+++ b/app/src/test/java/com/pasich/mynotes/data/sync/TwoPeerConvergenceTest.java
@@ -97,6 +97,13 @@ public void aRecordRevivedElsewhereLeavesNoPhantomDeletionOnAPeerThatNeverHeldIt
assertThat(a.store.pendingConflicts()).isEmpty();
assertThat(a.store.titleOf(type, RECORD_ID)).isEqualTo("TaskX-B");
+ // The tap that did the damage: accepting whatever the dialog pre-selects. With the row
+ // gone there is nothing to accept; with it present, this deleted the task on A.
+ clock.advance();
+ a.store.resolveAllKeepingWinner(clock.instant());
+ assertThat(a.store.titleOf(type, RECORD_ID)).isEqualTo("TaskX-B");
+ clock.advance();
+ assertThat(a.sync().getConflictCount()).isEqualTo(0);
clock.advance();
assertThat(c.sync().getConflictCount()).isEqualTo(0);
assertThat(c.store.titleOf(type, RECORD_ID)).isEqualTo("TaskX-B");
@@ -220,6 +227,7 @@ static final class PeerStore implements SyncStore {
private final Map