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
5 changes: 5 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -187,7 +187,12 @@ jobs:
# Posts, and on later pushes updates, a single comment showing overall coverage and the
# coverage of the files this PR actually changed. Both reports are passed together so the
# numbers reflect the unit and on-device suites combined.
#
# Skipped for a pull request from a fork: its GITHUB_TOKEN is read-only whatever the
# workflow asks for, so the comment call would fail with a 403 and turn the whole job red for
# a reason unrelated to the change.
- name: Comment coverage
if: github.event.pull_request.head.repo.full_name == github.repository
uses: madrapps/jacoco-report@v1.7.1
with:
paths: |
Expand Down
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# CHANGELOG

## [2.6.50] - 04.09.2026
## [2.6.51] - 05.09.2026

**New**

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 = 50
def appVersionCode = 51
def appVersionName = "2.6.${appVersionCode}"

def gitCommitHashProvider = providers.exec {
Expand Down
899 changes: 888 additions & 11 deletions app/src/androidTest/java/com/pasich/mynotes/db/RoomSyncStoreTest.java

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@

public abstract class BaseActivity extends AppCompatActivity implements BaseView {

@Inject ThemePreferencesCache themePreferencesCache;
@Inject protected ThemePreferencesCache themePreferencesCache;

@Override
public void selectTheme() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,10 @@ public interface NoteDao {
@Query("SELECT * FROM notes WHERE id = :id LIMIT 1")
Note getNoteSync(int id);

/** One round trip for a whole restore batch; the caller keeps the list under the bind limit. */
@Query("SELECT * FROM notes WHERE id IN (:ids)")
List<Note> getNotesByIdsSync(List<Integer> ids);

@Query("DELETE FROM notes WHERE id = :id")
void deleteById(int id);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,21 @@ public interface SyncConflictDao {
@Query("SELECT * FROM sync_conflicts WHERE id = :id LIMIT 1")
SyncConflictEntity getById(long id);

@Query("DELETE FROM sync_conflicts WHERE id = :id")
void deleteById(long id);

/**
* Retires every open conflict for a record whose winner is no longer the version being applied.
*
* <p>Such a row offers, pre-selected, a version the record has since moved past; applying it
* reverted the newer edit. The loser it carried is republished with the bundle and comes back
* against the current version.
*/
@Query(
"DELETE FROM sync_conflicts WHERE resolved = 0 AND recordType = :recordType "
+ "AND stableId = :stableId AND winnerVersionId != :winnerVersionId")
void deleteSupersededUnresolved(String recordType, String stableId, String winnerVersionId);

@Query(
"UPDATE sync_conflicts SET resolution = :resolution, resolved = 1, resolvedAt = :resolvedAt WHERE id = :id")
void markResolved(long id, String resolution, long resolvedAt);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,12 @@ public interface SyncMetadataDao {
+ "WHERE recordType = :recordType AND localId = :localId)")
boolean exists(String recordType, long localId);

/** The subset of {@code localIds} that already has a row; the caller chunks the list. */
@Query(
"SELECT localId FROM sync_metadata "
+ "WHERE recordType = :recordType AND localId IN (:localIds)")
List<Long> getExistingLocalIds(String recordType, List<Long> localIds);

@Query(
"SELECT * FROM sync_metadata WHERE recordType = :recordType AND stableId = :stableId LIMIT 1")
SyncMetadataEntity getByStableId(String recordType, String stableId);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ public interface TagsDao {
@Query("SELECT * FROM tags WHERE id = :id LIMIT 1")
Tag getTagSync(long id);

@Query("SELECT * FROM tags WHERE id IN (:ids)")
List<Tag> getTagsByIdsSync(List<Long> ids);

/** Tags are referenced by name from a note, so the name is their real identity. */
@Query("SELECT * FROM tags WHERE name = :name LIMIT 1")
Tag getTagByNameSync(String name);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,28 @@ public class AppPreferencesHelper implements PreferenceHelper {

private final ThemePreferencesCache themeCache;
private final SafePreferences prefs;
private final java.util.concurrent.Executor mainThread;

@Inject
AppPreferencesHelper(
AppPreferencesCache appCache, ThemePreferencesCache themeCache, SafePreferences prefs) {
this(
appCache,
themeCache,
prefs,
new android.os.Handler(android.os.Looper.getMainLooper())::post);
}

/** Test seam: where the theme application is posted to. */
AppPreferencesHelper(
AppPreferencesCache appCache,
ThemePreferencesCache themeCache,
SafePreferences prefs,
java.util.concurrent.Executor mainThread) {
this.prefs = prefs;
this.appCache = appCache;
this.themeCache = themeCache;
this.mainThread = mainThread;
this.appCache.initialize();
this.themeCache.initialize();
}
Expand Down Expand Up @@ -88,10 +103,19 @@ public PreferencesBackup getListPreferences() {
PreferencesConfig.ARGUMENT_DEFAULT_UI_SCALING_VALUE));
}

/** Persists all fields from a backup and refreshes the caches. */
/**
* Persists all fields from a backup and refreshes the caches.
*
* <p>The restore path. The theme is deliberately not applied here: {@code
* AppCompatDelegate.setDefaultNightMode} recreates every started activity when the mode
* changes, and this runs at the start of a restore whose note and tag inserts are still in
* flight on the Backup screen — recreating it disposed those inserts and left the database half
* restored with no message. The stored mode takes effect at the next activity creation, and the
* screen applies it itself once the restore has finished.
*/
@Override
public void setListPreferences(PreferencesBackup preferences) {
commitListPreferences(preferences);
commitListPreferences(preferences, false);
}

/**
Expand All @@ -103,10 +127,16 @@ public void setListPreferences(PreferencesBackup preferences) {
* plus {@code commit()} makes the whole set atomic and tells the caller whether it is durable,
* which is what lets {@code RoomSyncStore} decide when the journal may be dropped.
*
* <p>The sync path: a theme arriving from another device is applied at once.
*
* @return true when the values are durably stored, false when the write failed.
*/
@Override
public boolean commitListPreferences(PreferencesBackup preferences) {
return commitListPreferences(preferences, true);
}

private boolean commitListPreferences(PreferencesBackup preferences, boolean applyThemeNow) {
if (preferences == null || !preferences.isCreated()) {
return false;
}
Expand Down Expand Up @@ -137,12 +167,13 @@ public boolean commitListPreferences(PreferencesBackup preferences) {
}
appCache.refresh();
themeCache.refresh();
// Refreshing the caches only reloads the values. Light/dark is owned by
// AppCompatDelegate, which has to be told, or a theme arriving from another device sat
// in storage until the next activity was created. Posted to the main thread because this
// runs on a background thread for both a sync apply and a backup restore.
new android.os.Handler(android.os.Looper.getMainLooper())
.post(themeCache::applyCurrentThemeMode);
if (applyThemeNow) {
// Refreshing the caches only reloads the values. Light/dark is owned by
// AppCompatDelegate, which has to be told, or a theme arriving from another device
// sat in storage until the next activity was created. Posted to the main thread
// because a sync apply runs on a background thread.
mainThread.execute(themeCache::applyCurrentThemeMode);
}
return true;
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
package com.pasich.mynotes.data.sync;

import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import java.io.File;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.StandardCopyOption;
import java.util.LinkedHashMap;
import java.util.Map;

/**
* Remembers the hash of an attachment file until the file changes.
*
* <p>Every snapshot build used to read and digest every attachment in the library, and a sync
* builds at least once — twice when the first-sync estimate precedes it — so an idle sync of a
* library with gigabytes of attachments was dominated by re-hashing bytes nothing had touched. A
* file is keyed by its path, size and modification time, the same test a version control index
* uses; a file rewritten in place with identical size within the same millisecond is the one case
* this cannot see, and no writer in this app does that.
*
* <p>Persisted as JSON next to the download cache so the saving survives the store instance, which
* lives only as long as one sync. Loading, saving and every lookup is best effort: a lost or
* unreadable cache costs one full re-hash, never correctness.
*/
final class AttachmentHashCache {

/** Produces the hash when the cache has no answer. */
interface Hasher {
@NonNull
String sha256(@NonNull File file) throws IOException;
}

private static final class Entry {
final long size;
final long modifiedAt;
final String sha256;

Entry(long size, long modifiedAt, String sha256) {
this.size = size;
this.modifiedAt = modifiedAt;
this.sha256 = sha256;
}
}

@Nullable private final File storage;
private final Map<String, Entry> entries = new LinkedHashMap<>();
private boolean loaded;
private boolean dirty;

/**
* @param storage where the cache persists, or {@code null} to keep it in memory only.
*/
AttachmentHashCache(@Nullable File storage) {
this.storage = storage;
}

/** The file's hash, from the cache when its size and modification time still match. */
@NonNull
synchronized String sha256(@NonNull File file, @NonNull Hasher hasher) throws IOException {
load();
String key = file.getAbsolutePath();
long size = file.length();
long modifiedAt = file.lastModified();
Entry cached = entries.get(key);
if (cached != null && cached.size == size && cached.modifiedAt == modifiedAt) {
return cached.sha256;
}
String hash = hasher.sha256(file);
entries.put(key, new Entry(size, modifiedAt, hash));
dirty = true;
return hash;
}

/** Writes the cache if anything changed; a failure here is logged by nobody on purpose. */
synchronized void flush() {
if (!dirty || storage == null) {
return;
}
JsonObject root = new JsonObject();
for (Map.Entry<String, Entry> entry : entries.entrySet()) {
JsonObject value = new JsonObject();
value.addProperty("size", entry.getValue().size);
value.addProperty("modifiedAt", entry.getValue().modifiedAt);
value.addProperty("sha256", entry.getValue().sha256);
root.add(entry.getKey(), value);
}
File parent = storage.getParentFile();
if (parent != null && !parent.isDirectory() && !parent.mkdirs()) {
return;
}
File temporary = new File(parent, storage.getName() + ".tmp");
try {
Files.write(temporary.toPath(), root.toString().getBytes(StandardCharsets.UTF_8));
Files.move(
temporary.toPath(),
storage.toPath(),
StandardCopyOption.REPLACE_EXISTING,
StandardCopyOption.ATOMIC_MOVE);
dirty = false;
} catch (IOException | RuntimeException ignored) {
// The next build simply hashes again.
temporary.delete();
}
}

/** Forgets everything, in memory and on disk. */
synchronized void clear() {
entries.clear();
loaded = true;
dirty = false;
if (storage != null) {
storage.delete();
}
}

private void load() {
if (loaded) {
return;
}
loaded = true;
if (storage == null || !storage.isFile()) {
return;
}
try {
String json = new String(Files.readAllBytes(storage.toPath()), StandardCharsets.UTF_8);
JsonObject root = JsonParser.parseString(json).getAsJsonObject();
for (Map.Entry<String, JsonElement> entry : root.entrySet()) {
JsonObject value = entry.getValue().getAsJsonObject();
String sha256 = value.get("sha256").getAsString();
if (!sha256.matches("[0-9a-f]{64}")) {
continue;
}
entries.put(
entry.getKey(),
new Entry(
value.get("size").getAsLong(),
value.get("modifiedAt").getAsLong(),
sha256));
}
} catch (IOException | RuntimeException unreadable) {
entries.clear();
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package com.pasich.mynotes.data.sync;

import androidx.annotation.NonNull;
import java.nio.charset.StandardCharsets;
import java.util.UUID;

/**
* How an attachment that the editor never gave an id receives one.
*
* <p>Existing editor data predates logical attachment ids, so the store derives one from the note's
* stable id, the attachment's position, its stored URL and its display name. Two releases spell
* this differently: 2.6.50 stopped there, and its ids can therefore describe two different blobs at
* once, which failed every publish for an account whose note had a replaced attachment. Later
* releases fold in the content hash. Both spellings live here because the bundle decoder has to
* recognise the old one to upgrade a 2.6.50 payload into the current shape.
*/
final class AttachmentLogicalIds {

private AttachmentLogicalIds() {}

/** The current derivation: one id per (note, position, URL, name, content). */
@NonNull
static String derive(
@NonNull String noteStableId,
int index,
@NonNull String url,
@NonNull String displayName,
@NonNull String sha256) {
return nameUuid(
noteStableId + "\n" + index + "\n" + url + "\n" + displayName + "\n" + sha256);
}

/** The 2.6.50 derivation, kept only so its payloads can be recognised. */
@NonNull
static String deriveLegacy(
@NonNull String noteStableId,
int index,
@NonNull String url,
@NonNull String displayName) {
return nameUuid(noteStableId + "\n" + index + "\n" + url + "\n" + displayName);
}

@NonNull
private static String nameUuid(@NonNull String source) {
return UUID.nameUUIDFromBytes(source.getBytes(StandardCharsets.UTF_8)).toString();
}
}
Loading
Loading