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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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**
Expand Down
2 changes: 1 addition & 1 deletion app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -171,28 +190,77 @@ 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);

CapturingCallback<SyncState> 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<SyncState> 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
Expand Down Expand Up @@ -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<SyncConflictEntity> conflicts = new ArrayList<>();
private final List<String> resolutions = new ArrayList<>();
private String lastToken;

@NonNull
@Override
public SyncState readState() {
public SyncState readState() throws IOException {
if (readStateError != null) {
throw readStateError;
}
return state;
}

Expand Down
Loading