diff --git a/CHANGELOG.md b/CHANGELOG.md index 475288e1..166f2b5e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # CHANGELOG +## [2.6.49] - 01.09.2026 + +**Improvements** + +- **Google Drive sync for everyone:** The staged rollout is complete. All signed-in users who + explicitly confirm their first sync can now sync their notes, tasks, tags, preferences, and + attachments across devices. + ## [2.6.48] - 01.09.2026 **Improvements** diff --git a/app/build.gradle b/app/build.gradle index 78756569..a504a954 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 = 48 +def appVersionCode = 49 def appVersionName = "2.6.${appVersionCode}" def gitCommitHashProvider = providers.exec { diff --git a/app/src/main/java/com/pasich/mynotes/ui/sync/SyncCoordinator.java b/app/src/main/java/com/pasich/mynotes/ui/sync/SyncCoordinator.java index 5e0c78db..721d0ef9 100644 --- a/app/src/main/java/com/pasich/mynotes/ui/sync/SyncCoordinator.java +++ b/app/src/main/java/com/pasich/mynotes/ui/sync/SyncCoordinator.java @@ -94,7 +94,13 @@ public String getAvatarLabel() { private final BackgroundScheduler backgroundScheduler; private final Executor workerExecutor; private final Executor mainExecutor; - private static final int CURRENT_ROLLOUT_PERCENT = 10; + + /** + * The v2.6.48 sync safety release completed its staged rollout; sync is now available to all + * cohorts. + */ + private static final int CURRENT_ROLLOUT_PERCENT = 100; + private static final SecureRandom ROLLOUT_RANDOM = new SecureRandom(); public SyncCoordinator( diff --git a/app/src/test/java/com/pasich/mynotes/ui/sync/SyncCoordinatorTest.java b/app/src/test/java/com/pasich/mynotes/ui/sync/SyncCoordinatorTest.java index 42de454a..be1512e1 100644 --- a/app/src/test/java/com/pasich/mynotes/ui/sync/SyncCoordinatorTest.java +++ b/app/src/test/java/com/pasich/mynotes/ui/sync/SyncCoordinatorTest.java @@ -14,6 +14,7 @@ import com.pasich.mynotes.utils.auth.GoogleCredential; import com.pasich.mynotes.utils.auth.GoogleCredentialAuth; import com.pasich.mynotes.utils.auth.GoogleDriveAuthorization; +import java.io.IOException; import java.time.Instant; import java.util.ArrayList; import java.util.List; @@ -68,6 +69,24 @@ public void backgroundSync_waitsForFirstSyncConfirmation() { assertThat(scheduler.enableCalls).isEqualTo(1); } + @Test + public void getLastState_returnsIdleWhenStoredSyncStateCannotBeRead() { + FakeConflictStore store = new FakeConflictStore(); + store.readStateError = new IOException("unreadable sync state"); + SyncCoordinator coordinator = + new SyncCoordinator( + new FakePreferenceHelper(), + firebaseAuth(null), + mock(GoogleCredentialAuth.class), + mock(GoogleDriveAuthorization.class), + store, + new FakeScheduler(), + directExecutor, + directExecutor); + + assertThat(coordinator.getLastState().getStatus()).isEqualTo(SyncState.Status.IDLE); + } + @Test public void connect_marksSyncEnabledAndReturnsProfile() { FakePreferenceHelper preferences = new FakePreferenceHelper(); @@ -171,18 +190,30 @@ public void syncNow_requiresFirstSyncConfirmationBeforeAuthorizing() { } @Test - public void syncNow_blocksUsersOutsideTheStagedRollout() { + public void syncNow_allowsUsersInTheHighestRolloutBucket() { FakePreferenceHelper preferences = new FakePreferenceHelper(); preferences.firstSyncConfirmed = true; - preferences.rolloutBucket = 11; + preferences.rolloutBucket = 100; GoogleDriveAuthorization authorization = mock(GoogleDriveAuthorization.class); + Mockito.doAnswer( + invocation -> { + GoogleDriveAuthorization.Callback callback = invocation.getArgument(1); + callback.onAuthorized("access-token"); + return null; + }) + .when(authorization) + .authorize( + Mockito.any(Activity.class), + Mockito.any(GoogleDriveAuthorization.Callback.class)); + FakeConflictStore store = new FakeConflictStore(); + store.state = SyncState.success("google-drive", Instant.parse("2026-09-01T12:00:00Z"), 1); SyncCoordinator coordinator = new SyncCoordinator( preferences, firebaseAuth(mock(FirebaseUser.class)), mock(GoogleCredentialAuth.class), authorization, - new FakeConflictStore(), + store, new FakeScheduler(), directExecutor, directExecutor); @@ -190,9 +221,46 @@ public void syncNow_blocksUsersOutsideTheStagedRollout() { CapturingCallback callback = new CapturingCallback<>(); coordinator.syncNow(mock(Activity.class), callback); - assertThat(callback.value).isNull(); - assertThat(callback.error).hasMessageThat().contains("not available in this rollout"); - Mockito.verifyNoInteractions(authorization); + assertThat(store.lastToken).isEqualTo("access-token"); + assertThat(callback.error).isNull(); + assertThat(callback.value.getStatus()).isEqualTo(SyncState.Status.SUCCESS); + } + + @Test + public void syncNow_repairsAnInvalidStoredRolloutBucketBeforeSyncing() { + FakePreferenceHelper preferences = new FakePreferenceHelper(); + preferences.firstSyncConfirmed = true; + preferences.rolloutBucket = 0; + GoogleDriveAuthorization authorization = mock(GoogleDriveAuthorization.class); + Mockito.doAnswer( + invocation -> { + GoogleDriveAuthorization.Callback callback = invocation.getArgument(1); + callback.onAuthorized("access-token"); + return null; + }) + .when(authorization) + .authorize( + Mockito.any(Activity.class), + Mockito.any(GoogleDriveAuthorization.Callback.class)); + FakeConflictStore store = new FakeConflictStore(); + store.state = SyncState.success("google-drive", Instant.parse("2026-09-01T12:00:00Z"), 1); + SyncCoordinator coordinator = + new SyncCoordinator( + preferences, + firebaseAuth(mock(FirebaseUser.class)), + mock(GoogleCredentialAuth.class), + authorization, + store, + new FakeScheduler(), + directExecutor, + directExecutor); + + CapturingCallback callback = new CapturingCallback<>(); + coordinator.syncNow(mock(Activity.class), callback); + + assertThat(preferences.rolloutBucket >= 1 && preferences.rolloutBucket <= 100).isTrue(); + assertThat(store.lastToken).isEqualTo("access-token"); + assertThat(callback.error).isNull(); } @Test @@ -268,13 +336,17 @@ public void disable() { private static final class FakeConflictStore implements SyncCoordinator.ConflictStore { private SyncState state = SyncState.idle(); + private IOException readStateError; private final List conflicts = new ArrayList<>(); private final List resolutions = new ArrayList<>(); private String lastToken; @NonNull @Override - public SyncState readState() { + public SyncState readState() throws IOException { + if (readStateError != null) { + throw readStateError; + } return state; }