diff --git a/CHANGELOG.md b/CHANGELOG.md index d80eab13..ae506db9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,76 @@ # CHANGELOG +## [2.7.56] - 05.10.2026 + +**New** + +- **Meet Encly.** A new page in the menu, and a one-time introduction after this update, presents + Encly, the encrypted, offline successor to My Notes. With Encly installed, one tap moves your + notes (the trash included), tasks, tags and task lists to it; the page tells you what stays + behind (attachments, images, reminders and pins). Nothing is deleted from My Notes, and clearing + it after a successful move is a separate, confirmed step. +- **Undo and Redo.** While you edit, Undo and Redo sit on a slim bar right above the keyboard in + both editors, next to a button that puts the keyboard away; with the keyboard closed they are in + More. They step back through typing, deletions, pastes, formatting and blocks, and put the + cursor back where it was. Ctrl+Z and Ctrl+Shift+Z or Ctrl+Y work on a hardware keyboard. The + history belongs to the note you are editing and survives turning the screen. +- **Version history.** More → Version history lists earlier versions of a note with when and why + each was kept: while you edit, and always before a sync, a conflict choice or a restore replaces + the text. Open one to see it next to the current note with the changed words highlighted, and + restore it in one tap; the text it replaces is kept too, so a restore can be undone. Up to 20 + versions per note stay on this device only and are not synced or backed up. +- **Arrange notes yourself.** Choose **Custom order** in View options (the button in the search + bar), then press and hold a note and drag it where you want it, in the list or in the grid; the + list scrolls when you hold a note near the top or bottom. Notes you can move show a small drag + handle, and the first time you long-press a note in another order the app offers to turn the + custom order on. Pinned notes stay in their own section above the others. The order stays on + this device and is saved in local backups. With TalkBack, each note offers Move up and Move down. +- **Custom repeat for reminders.** Next to Daily, Weekly and Monthly, Custom… repeats a reminder + every so many hours, days, weeks, months or years, shown as in "Every 3 hours" on the card, at + the top of the note and in More. Repeats never drift: a monthly reminder on the 31st rings on + the last day of shorter months and is back on the 31st afterwards. +- **Notes reopen where you left them.** A long note opens at the line you were reading, with the + cursor where it was if you were editing, in both editors and after restarting the app. If the + note changed meanwhile, the same place is found by the text around it. +- **Choose how notes open.** Settings → Interaction lets you open notes in reading mode, editing + mode or automatically as before, where you left off or always at the beginning, and optionally + double-tap the text in reading mode to start editing right there. New notes always open ready + for typing. + +**Improvements** + +- **Search** puts the best match first: exact titles, then titles that start with or contain what + you typed, then matching tags, and only then the text of notes. Case, accents and extra spaces + no longer matter, so "cafe" finds "Café", and the matching part of each result is highlighted. +- **Simpler toolbars.** Sorting and the list or grid layout live together under one View options + button in the search bar. The note toolbar keeps only Back, the save status and More, and an + upcoming reminder shows as a chip at the top of the note that you tap to change it. +- **Tidier menu.** The menu puts Tags and Trash first, then Tasks, with a single way to support + the developer, and closes when you open a screen from it. + +**Fixes** + +- **The editor keeps your place.** Opening or closing the keyboard, tapping Edit or switching + reading mode no longer moves the text or sends the cursor to the start or end of the note, the + keyboard opens reliably in the rich editor, and reading mode locks the title too. +- **Nothing is lost when you leave.** What you typed in the last moments is saved when you switch + apps, turn the screen or open the file picker, and the note reopens at the same place. +- **Pictures in the rich editor** no longer make the note flicker, jump or flash white, and a + picture picked while the screen was turned is still added where you inserted it. +- **The note list stays right.** No more empty list after a Google Drive sync, after choosing an + empty tag or after moving the top note to the trash; cards no longer slide over one another when + the list changes; and after creating, editing, pinning or restoring a note the list comes back + at the top with the search bar and tags in view. +- **Syncing is lighter.** A sync writes only what another device actually changed, so a sync with + nothing new no longer redraws the list, and a tag renamed, merged or deleted elsewhere keeps the + right tag selected here. +- **Reminders are dependable.** A repeating reminder missed while the phone was off arrives once + and continues on schedule, snoozing no longer cancels it, a change made on another device takes + effect after the sync, and tapping a reminder opens the note in the editor it belongs to. +- **Smaller fixes.** Labels in the More and reminder sheets are no longer cut off with large + text, buttons are named for what they do for screen readers, and a card swiped left to select + it slides back into place. + ## [2.6.55] - 25.09.2026 **Improvements** diff --git a/README.md b/README.md index bb839688..26f26552 100644 --- a/README.md +++ b/README.md @@ -30,17 +30,26 @@ No ads or tracking — your content stays on your device unless you explicitly e +
+ + +
+ ## Features - 📥 Import from **Google Keep** - 🏷️ **Tags** for sorting and searching notes - ✍️ **Advanced editor** — headings, lists, quotes, formatting (Editor.js) -- 🔔 **Reminders** with repeat schedules +- ↩️ **Undo and redo** while you type, right above the keyboard +- 🕘 **Version history** — preview what changed and restore an earlier version +- ↕️ **Custom order** — drag notes into the order you want, in the list or the grid +- 🔔 **Reminders** with repeat schedules, including custom ones like every 3 hours - 🎨 **Themes and colors** to match your mood - 🌍 **10 languages** — EN, UK, RU, DE, FR, ES, IT, PL, BE, KK - 🚫 **No ads** — just your notes - 🔒 **Local-first** — works fully offline; Google Drive sync is optional - 💻 **Open-source** — transparent and accessible code +- 🔐 **Move to Encly** — hand your notes over to Encly, the encrypted successor app, in one step - 🎯 **Modern and intuitive design** ## Privacy diff --git a/app/build.gradle b/app/build.gradle index cf28a82c..6ac0f0e1 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -21,8 +21,8 @@ apply from: "$projectDir/gradle/libs-task.gradle" apply from: "$projectDir/gradle/changelog-task.gradle" -def appVersionCode = 55 -def appVersionName = "2.6.${appVersionCode}" +def appVersionCode = 56 +def appVersionName = "2.7.${appVersionCode}" def gitCommitHashProvider = providers.exec { commandLine 'git', 'rev-parse', '--short', 'HEAD' @@ -137,6 +137,7 @@ android { testOptions { unitTests { returnDefaultValues = true + includeAndroidResources = true } } } @@ -215,6 +216,7 @@ dependencies { testImplementation 'junit:junit:4.13.2' testImplementation 'org.mockito:mockito-core:5.23.0' testImplementation 'com.google.truth:truth:1.4.2' + testImplementation 'org.robolectric:robolectric:4.16.1' // Room testing (integration tests) testImplementation "androidx.room:room-testing:$roomVersion" diff --git a/app/schemas/com.pasich.mynotes.data.database.AppDatabase/23.json b/app/schemas/com.pasich.mynotes.data.database.AppDatabase/23.json new file mode 100644 index 00000000..e419f606 --- /dev/null +++ b/app/schemas/com.pasich.mynotes.data.database.AppDatabase/23.json @@ -0,0 +1,675 @@ +{ + "formatVersion": 1, + "database": { + "version": 23, + "identityHash": "8ca721e7c2f8bf38a8a385a796e40cb7", + "entities": [ + { + "tableName": "tags", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `visibility` INTEGER NOT NULL, `systemAction` INTEGER NOT NULL, `position` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "nameTag", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "visibility", + "columnName": "visibility", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "systemAction", + "columnName": "systemAction", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "notes", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `title` TEXT, `value` TEXT, `date` INTEGER NOT NULL, `tag` TEXT, `valueJson` TEXT, `hasRichContent` INTEGER NOT NULL, `attachments` TEXT, `isTrash` INTEGER NOT NULL, `reminderTime` INTEGER, `isPinned` INTEGER NOT NULL, `reminderRepeat` TEXT NOT NULL, `reminderIntervalMinutes` INTEGER NOT NULL, `customPosition` INTEGER NOT NULL DEFAULT 0)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT" + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "TEXT" + }, + { + "fieldPath": "date", + "columnName": "date", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tag", + "columnName": "tag", + "affinity": "TEXT" + }, + { + "fieldPath": "valueJson", + "columnName": "valueJson", + "affinity": "TEXT" + }, + { + "fieldPath": "hasRichContent", + "columnName": "hasRichContent", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "attachments", + "columnName": "attachments", + "affinity": "TEXT" + }, + { + "fieldPath": "isTrash", + "columnName": "isTrash", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "reminderTime", + "columnName": "reminderTime", + "affinity": "INTEGER" + }, + { + "fieldPath": "isPinned", + "columnName": "isPinned", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "reminderRepeat", + "columnName": "reminderRepeat", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "reminderIntervalMinutes", + "columnName": "reminderIntervalMinutes", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "customPosition", + "columnName": "customPosition", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "tasks", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `title` TEXT NOT NULL, `description` TEXT, `isDone` INTEGER NOT NULL DEFAULT 0, `categoryId` INTEGER NOT NULL DEFAULT 0, `createdAt` INTEGER NOT NULL DEFAULT 0, `position` INTEGER NOT NULL DEFAULT 0, `reminderTime` INTEGER, `reminderIntervalMinutes` INTEGER NOT NULL DEFAULT 0)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT" + }, + { + "fieldPath": "isDone", + "columnName": "isDone", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "categoryId", + "columnName": "categoryId", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "reminderTime", + "columnName": "reminderTime", + "affinity": "INTEGER" + }, + { + "fieldPath": "reminderIntervalMinutes", + "columnName": "reminderIntervalMinutes", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "task_categories", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `colorHex` TEXT NOT NULL DEFAULT '#6750A4', `position` INTEGER NOT NULL DEFAULT 0)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "colorHex", + "columnName": "colorHex", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'#6750A4'" + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "sync_metadata", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`recordType` TEXT NOT NULL, `localId` INTEGER NOT NULL, `stableId` TEXT NOT NULL, `updatedAt` INTEGER NOT NULL, `deletedAt` INTEGER, `syncedVersionId` TEXT, PRIMARY KEY(`recordType`, `localId`))", + "fields": [ + { + "fieldPath": "recordType", + "columnName": "recordType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "localId", + "columnName": "localId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "stableId", + "columnName": "stableId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "syncedVersionId", + "columnName": "syncedVersionId", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "recordType", + "localId" + ] + }, + "indices": [ + { + "name": "index_sync_metadata_recordType_stableId", + "unique": true, + "columnNames": [ + "recordType", + "stableId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_sync_metadata_recordType_stableId` ON `${TABLE_NAME}` (`recordType`, `stableId`)" + }, + { + "name": "index_sync_metadata_updatedAt", + "unique": false, + "columnNames": [ + "updatedAt" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_sync_metadata_updatedAt` ON `${TABLE_NAME}` (`updatedAt`)" + }, + { + "name": "index_sync_metadata_deletedAt", + "unique": false, + "columnNames": [ + "deletedAt" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_sync_metadata_deletedAt` ON `${TABLE_NAME}` (`deletedAt`)" + } + ] + }, + { + "tableName": "sync_pending_preferences", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `payloadJson` TEXT NOT NULL, `targetHash` TEXT NOT NULL, `baselineHash` TEXT NOT NULL, `recordUpdatedAt` INTEGER NOT NULL, `quarantined` INTEGER NOT NULL, `conflictId` INTEGER NOT NULL, `conflictResolution` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "payloadJson", + "columnName": "payloadJson", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "targetHash", + "columnName": "targetHash", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "baselineHash", + "columnName": "baselineHash", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "recordUpdatedAt", + "columnName": "recordUpdatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "quarantined", + "columnName": "quarantined", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "conflictId", + "columnName": "conflictId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "conflictResolution", + "columnName": "conflictResolution", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "sync_conflicts", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `recordType` TEXT NOT NULL, `stableId` TEXT NOT NULL, `versionPairHash` TEXT NOT NULL, `winnerSource` TEXT NOT NULL, `loserSource` TEXT NOT NULL, `winnerVersionId` TEXT NOT NULL, `loserVersionId` TEXT NOT NULL, `winnerJson` TEXT NOT NULL, `loserJson` TEXT NOT NULL, `winnerUpdatedAt` INTEGER NOT NULL, `loserUpdatedAt` INTEGER NOT NULL, `winnerTombstone` INTEGER NOT NULL, `loserTombstone` INTEGER NOT NULL, `resolution` TEXT NOT NULL, `resolved` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `resolvedAt` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "recordType", + "columnName": "recordType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "stableId", + "columnName": "stableId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "versionPairHash", + "columnName": "versionPairHash", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "winnerSource", + "columnName": "winnerSource", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "loserSource", + "columnName": "loserSource", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "winnerVersionId", + "columnName": "winnerVersionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "loserVersionId", + "columnName": "loserVersionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "winnerJson", + "columnName": "winnerJson", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "loserJson", + "columnName": "loserJson", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "winnerUpdatedAt", + "columnName": "winnerUpdatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "loserUpdatedAt", + "columnName": "loserUpdatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "winnerTombstone", + "columnName": "winnerTombstone", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "loserTombstone", + "columnName": "loserTombstone", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "resolution", + "columnName": "resolution", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "resolved", + "columnName": "resolved", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "resolvedAt", + "columnName": "resolvedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_sync_conflicts_recordType_stableId_versionPairHash", + "unique": true, + "columnNames": [ + "recordType", + "stableId", + "versionPairHash" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_sync_conflicts_recordType_stableId_versionPairHash` ON `${TABLE_NAME}` (`recordType`, `stableId`, `versionPairHash`)" + }, + { + "name": "index_sync_conflicts_resolved", + "unique": false, + "columnNames": [ + "resolved" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_sync_conflicts_resolved` ON `${TABLE_NAME}` (`resolved`)" + }, + { + "name": "index_sync_conflicts_createdAt", + "unique": false, + "columnNames": [ + "createdAt" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_sync_conflicts_createdAt` ON `${TABLE_NAME}` (`createdAt`)" + } + ] + }, + { + "tableName": "sync_state", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `status` TEXT NOT NULL, `backendIdentifier` TEXT, `lastSuccessfulSyncAt` INTEGER, `attemptStartedAt` INTEGER, `errorMessage` TEXT, `conflictCount` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "backendIdentifier", + "columnName": "backendIdentifier", + "affinity": "TEXT" + }, + { + "fieldPath": "lastSuccessfulSyncAt", + "columnName": "lastSuccessfulSyncAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "attemptStartedAt", + "columnName": "attemptStartedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "errorMessage", + "columnName": "errorMessage", + "affinity": "TEXT" + }, + { + "fieldPath": "conflictCount", + "columnName": "conflictCount", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "note_versions", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `noteLocalId` INTEGER NOT NULL, `noteStableId` TEXT, `title` TEXT NOT NULL, `value` TEXT NOT NULL, `valueJson` TEXT, `attachments` TEXT, `createdAt` INTEGER NOT NULL, `reason` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "noteLocalId", + "columnName": "noteLocalId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "noteStableId", + "columnName": "noteStableId", + "affinity": "TEXT" + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "valueJson", + "columnName": "valueJson", + "affinity": "TEXT" + }, + { + "fieldPath": "attachments", + "columnName": "attachments", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "reason", + "columnName": "reason", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_note_versions_noteLocalId_createdAt", + "unique": false, + "columnNames": [ + "noteLocalId", + "createdAt" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_note_versions_noteLocalId_createdAt` ON `${TABLE_NAME}` (`noteLocalId`, `createdAt`)" + }, + { + "name": "index_note_versions_noteStableId", + "unique": false, + "columnNames": [ + "noteStableId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_note_versions_noteStableId` ON `${TABLE_NAME}` (`noteStableId`)" + } + ] + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '8ca721e7c2f8bf38a8a385a796e40cb7')" + ] + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/com/pasich/mynotes/data/AttachmentStorageTest.java b/app/src/androidTest/java/com/pasich/mynotes/data/AttachmentStorageTest.java index 85dae775..0321f198 100644 --- a/app/src/androidTest/java/com/pasich/mynotes/data/AttachmentStorageTest.java +++ b/app/src/androidTest/java/com/pasich/mynotes/data/AttachmentStorageTest.java @@ -48,6 +48,13 @@ public void cleanup_withNoAttachments_deletesOrphanFiles() throws Exception { noteFolder.mkdirs(); File orphan = new File(noteFolder, "orphan.jpg"); orphan.createNewFile(); + // Older than the grace period that protects a just-inserted image. + assertThat( + orphan.setLastModified( + System.currentTimeMillis() + - AttachmentCleaner.GRACE_PERIOD_MS + - 60_000L)) + .isTrue(); com.pasich.mynotes.data.model.Note emptyNote = new com.pasich.mynotes.data.model.Note() @@ -58,4 +65,24 @@ public void cleanup_withNoAttachments_deletesOrphanFiles() throws Exception { assertThat(orphan.exists()).isFalse(); } + + @Test + public void cleanup_keepsAnOrphanFileInsideTheGracePeriod() throws Exception { + File base = new File(context.getFilesDir(), AttachmentStorage.ATTACHMENTS_BASE_DIR); + File noteFolder = new File(base, "note_66667"); + noteFolder.mkdirs(); + File fresh = new File(noteFolder, "fresh.jpg"); + fresh.createNewFile(); + + com.pasich.mynotes.data.model.Note emptyNote = + new com.pasich.mynotes.data.model.Note() + .create("", "", System.currentTimeMillis(), ""); + emptyNote.setId(66667); + + AttachmentCleaner.cleanup(context, emptyNote); + + // An image saved moments ago may not be in the saved note yet: it stays. + assertThat(fresh.exists()).isTrue(); + fresh.delete(); + } } diff --git a/app/src/androidTest/java/com/pasich/mynotes/db/MigrationTest.java b/app/src/androidTest/java/com/pasich/mynotes/db/MigrationTest.java index 342b7f34..8ebb25a9 100644 --- a/app/src/androidTest/java/com/pasich/mynotes/db/MigrationTest.java +++ b/app/src/androidTest/java/com/pasich/mynotes/db/MigrationTest.java @@ -220,6 +220,57 @@ public void migrate21to22_addsTheSyncedVersionAndLeavesItUnknownForExistingRows( } } + @Test + public void migrate22to23_addsVersionHistoryAndSeedsTheCustomOrderNewestFirst() + throws IOException { + SupportSQLiteDatabase db = helper.createDatabase(TEST_DB, 22); + String columns = + "INSERT INTO notes " + + "(id, title, value, date, tag, valueJson, hasRichContent, attachments, " + + "isTrash, reminderTime, isPinned, reminderRepeat, reminderIntervalMinutes) " + + "VALUES "; + db.execSQL(columns + "(1, 'Old', 'a', 100, '', '', 0, '', 0, NULL, 0, 'NONE', 0)"); + db.execSQL(columns + "(2, 'New', 'b', 300, '', '', 0, '', 0, NULL, 0, 'NONE', 0)"); + db.execSQL(columns + "(3, 'Middle', 'c', 200, '', '', 0, '', 0, NULL, 0, 'NONE', 0)"); + db.close(); + + SupportSQLiteDatabase migrated = + helper.runMigrationsAndValidate(TEST_DB, 23, true, AppDatabase.MIGRATION_22_23); + try (android.database.Cursor cursor = + migrated.query("SELECT id FROM notes ORDER BY customPosition DESC")) { + // The custom order starts as the default one, newest edit first. + assertThat(cursor.moveToNext()).isTrue(); + assertThat(cursor.getInt(0)).isEqualTo(2); + assertThat(cursor.moveToNext()).isTrue(); + assertThat(cursor.getInt(0)).isEqualTo(3); + assertThat(cursor.moveToNext()).isTrue(); + assertThat(cursor.getInt(0)).isEqualTo(1); + } + try (android.database.Cursor cursor = + migrated.query("SELECT COUNT(DISTINCT customPosition) FROM notes")) { + assertThat(cursor.moveToFirst()).isTrue(); + assertThat(cursor.getInt(0)).isEqualTo(3); + } + migrated.execSQL( + "INSERT INTO note_versions (noteLocalId, noteStableId, title, value, valueJson, " + + "attachments, createdAt, reason) " + + "VALUES (1, NULL, 'Old', 'a', NULL, NULL, 5, 'AUTOSAVE')"); + // No foreign key: replacing the note row must leave its history alone. + migrated.execSQL( + "INSERT OR REPLACE INTO notes " + + "(id, title, value, date, tag, valueJson, hasRichContent, attachments, " + + "isTrash, reminderTime, isPinned, reminderRepeat, " + + "reminderIntervalMinutes, customPosition) " + + "VALUES (1, 'Old', 'z', 400, '', '', 0, '', 0, NULL, 0, 'NONE', 0, 1024)"); + try (android.database.Cursor cursor = + migrated.query("SELECT COUNT(*) FROM note_versions WHERE noteLocalId = 1")) { + assertThat(cursor.moveToFirst()).isTrue(); + assertThat(cursor.getInt(0)).isEqualTo(1); + } finally { + migrated.close(); + } + } + @Test public void migrateFromTheLastReleasedVersion_reachesTheCurrentSchema() throws IOException { // 17 is what 2.6.48 shipped; everything after it lands in later releases of this line. @@ -234,13 +285,14 @@ public void migrateFromTheLastReleasedVersion_reachesTheCurrentSchema() throws I SupportSQLiteDatabase migrated = helper.runMigrationsAndValidate( TEST_DB, - 22, + 23, true, AppDatabase.MIGRATION_17_18, AppDatabase.MIGRATION_18_19, AppDatabase.MIGRATION_19_20, AppDatabase.MIGRATION_20_21, - AppDatabase.MIGRATION_21_22); + AppDatabase.MIGRATION_21_22, + AppDatabase.MIGRATION_22_23); try (android.database.Cursor cursor = migrated.query("SELECT COUNT(*) FROM notes")) { assertThat(cursor.moveToFirst()).isTrue(); assertThat(cursor.getInt(0)).isEqualTo(1); 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 2132dcfb..fd7124f4 100644 --- a/app/src/androidTest/java/com/pasich/mynotes/db/RoomSyncStoreTest.java +++ b/app/src/androidTest/java/com/pasich/mynotes/db/RoomSyncStoreTest.java @@ -256,6 +256,34 @@ public void applySnapshot_reapplyingTheLocalVersionKeepsItsAttachments() throws assertThat(readAll(new java.io.FileInputStream(restored))).isEqualTo(bytes); } + @Test + public void applySnapshot_aSecondSyncOfAnUnchangedNoteWithAnAttachmentReEmitsNothing() + throws Exception { + byte[] bytes = "photo bytes".getBytes(StandardCharsets.UTF_8); + int noteId = seedNoteWithAttachment("photo.png", bytes); + // The first apply moves the file to this device's canonical name and rewrites the column; + // that is a real change and is written. + store.applySnapshot(store.readSnapshot(), Collections.emptyList()); + String afterFirst = db.noteDao().getNoteSync(noteId).getAttachments(); + SyncSnapshot unchanged = store.readSnapshot(); + io.reactivex.subscribers.TestSubscriber> notes = + db.noteDao().getNotesAll().test(); + notes.awaitCount( + 1, io.reactivex.observers.BaseTestConsumer.TestWaitStrategy.SLEEP_10MS, 5_000L); + assertThat(notes.valueCount()).isEqualTo(1); + + store.applySnapshot(unchanged, Collections.emptyList()); + + // Every later sync re-applied the note with a REPLACE insert, so the main list was + // re-emitted and re-animated after every sync that changed nothing. + notes.awaitCount( + 2, io.reactivex.observers.BaseTestConsumer.TestWaitStrategy.SLEEP_10MS, 1_000L); + assertThat(notes.valueCount()).isEqualTo(1); + assertThat(db.noteDao().getNoteSync(noteId).getAttachments()).isEqualTo(afterFirst); + assertThat(resolveFirstAttachment(afterFirst).isFile()).isTrue(); + notes.dispose(); + } + @Test public void applySnapshot_repointsEditorBlocksAtTheFilesThisDeviceWrote() throws Exception { byte[] bytes = "photo bytes".getBytes(StandardCharsets.UTF_8); @@ -418,6 +446,40 @@ public void aNoteReceivedFromAnotherDeviceHashesIdenticallyWhenRebuiltThere() th } } + // ------------------------------------------------- version history across a sync + + @Test + public void applySnapshot_keepsTheReplacedTextInHistoryAndKeepsTheCustomPosition() + throws Exception { + int noteId = seedNote("Shopping", "milk", null); + Note seeded = db.noteDao().getNoteSync(noteId); + seeded.setCustomPosition(7_168L); + db.noteDao().addNote(seeded); + SyncRecord local = onlyNote(store.readSnapshot()); + JsonObject payload = local.getPayload().deepCopy(); + payload.addProperty("c", "milk, bread from the other phone"); + SyncRecord remote = + SyncRecord.live( + SyncRecord.Type.NOTE, + local.getId(), + local.getUpdatedAt().plusSeconds(60), + payload); + + store.applySnapshot( + new SyncSnapshot(Collections.singletonList(remote)), Collections.emptyList()); + + // The note row is written with a REPLACE insert, which SQLite carries out as a delete and + // an insert; a cascading foreign key would have wiped the history right here. + Note applied = db.noteDao().getNoteSync(noteId); + assertThat(applied.getValue()).isEqualTo("milk, bread from the other phone"); + assertThat(applied.getCustomPosition()).isEqualTo(7_168L); + List history = + db.noteVersionDao().getForNoteSync(noteId); + assertThat(history).hasSize(1); + assertThat(history.get(0).value).isEqualTo("milk"); + assertThat(history.get(0).reason).isEqualTo("PRE_SYNC"); + } + // ------------------------------------------------- an ordinary edit after a clean sync @Test diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 8c05d027..14b61180 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -29,6 +29,10 @@ + + + + + @@ -174,6 +184,8 @@ android:exported="true"> + + diff --git a/app/src/main/assets/editor/css/editor.css b/app/src/main/assets/editor/css/editor.css index 028934ea..47b3a58f 100644 --- a/app/src/main/assets/editor/css/editor.css +++ b/app/src/main/assets/editor/css/editor.css @@ -18,7 +18,9 @@ body { margin: 0; padding: 16px; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; - background-color: var(--surface-color, #ffffff); + /* Transparent until Android sends the theme: the WebView's own background, set from the + app theme before the page loads, shows through instead of a white flash. */ + background-color: var(--surface-color, transparent); color: var(--on-surface-color, #000000); } .codex-editor { @@ -267,3 +269,28 @@ body { } .ce-spacer-m { height: 25px } + +/* Images: the box keeps the picture's proportions from the moment it is known, so inserting or + loading an image never changes the height of the note under the caret. */ +.image-tool--uploading .image-tool__image { + background-color: var(--surface-variant-color, transparent) !important; + border-color: var(--outline-color, rgba(201, 201, 204, 0.48)) !important; + border-radius: 12px; +} + +.image-tool--reserved:not(.image-tool--filled) .image-tool__image { + display: flex; + align-items: center; + width: 100%; + min-height: 0; + aspect-ratio: var(--reserved-ratio); + background-color: var(--surface-variant-color, transparent); + border-radius: 12px; + box-sizing: border-box; +} + +.image-tool--uploading .image-tool__image-preloader { + background-color: var(--surface-color, transparent); + --bg-color: var(--surface-variant-color, #cdd1e0); + --front-color: var(--primary-color, #388ae5); +} diff --git a/app/src/main/assets/editor/js/editor-init.min.js b/app/src/main/assets/editor/js/editor-init.min.js index ee93fd40..bc346be7 100644 --- a/app/src/main/assets/editor/js/editor-init.min.js +++ b/app/src/main/assets/editor/js/editor-init.min.js @@ -96,4 +96,4 @@ let n$1=class n{static get isReadOnlySupported(){return!0}static get contentless * } * }, * }, - */class P{constructor({data:i,config:a,api:s,readOnly:r,block:o}){this.isCaptionEnabled=null,this.api=s,this.block=o,this.config={endpoints:a.endpoints,additionalRequestData:a.additionalRequestData,additionalRequestHeaders:a.additionalRequestHeaders,field:a.field,types:a.types,captionPlaceholder:this.api.i18n.t(a.captionPlaceholder??"Caption"),buttonContent:a.buttonContent,uploader:a.uploader,actions:a.actions,features:a.features||{}},this.uploader=new A({config:this.config,onUpload:e=>this.onUpload(e),onError:e=>this.uploadingFailed(e)}),this.ui=new D({api:s,config:this.config,onSelectFile:()=>{this.uploader.uploadSelectedFile({onPreview:e=>{this.ui.showPreloader(e)}})},readOnly:r}),this._data={caption:"",withBorder:!1,withBackground:!1,stretched:!1,file:{url:""}},this.data=i}static get isReadOnlySupported(){return!0}static get toolbox(){return{icon:L,title:"Image"}}static get tunes(){return[{name:"withBorder",icon:'',title:"With border",toggle:!0},{name:"stretched",icon:'',title:"Stretch image",toggle:!0},{name:"withBackground",icon:'',title:"With background",toggle:!0}]}render(){var i,a,s;return(!0===(null==(i=this.config.features)?void 0:i.caption)||void 0===(null==(a=this.config.features)?void 0:a.caption)||"optional"===(null==(s=this.config.features)?void 0:s.caption)&&this.data.caption)&&(this.isCaptionEnabled=!0,this.ui.applyTune("caption",!0)),this.ui.render()}validate(i){return!!i.file.url}save(){const i=this.ui.nodes.caption;return this._data.caption=i.innerHTML,this.data}renderSettings(){var o;const i=P.tunes.concat(this.config.actions||[]),a={border:"withBorder",background:"withBackground",stretch:"stretched",caption:"caption"};"optional"===(null==(o=this.config.features)?void 0:o.caption)&&i.push({name:"caption",icon:'',title:"With caption",toggle:!0});const s=i.filter(e=>{var v,l;const d=Object.keys(a).find(f=>a[f]===e.name);return"caption"===d?!1!==(null==(v=this.config.features)?void 0:v.caption):null==d||!1!==(null==(l=this.config.features)?void 0:l[d])}),r=e=>{let d=this.data[e.name];return"caption"===e.name&&(d=this.isCaptionEnabled??d),d};return s.map(e=>({icon:e.icon,label:this.api.i18n.t(e.title),name:e.name,toggle:e.toggle,isActive:r(e),onActivate:()=>{if("function"==typeof e.action)return void e.action(e.name);let d=!r(e);"caption"===e.name&&(this.isCaptionEnabled=!this.isCaptionEnabled,d=this.isCaptionEnabled),this.tuneToggled(e.name,d)}}))}appendCallback(){this.ui.nodes.fileButton.click()}static get pasteConfig(){return{tags:[{img:{src:!0}}],patterns:{image:/https?:\/\/\S+\.(gif|jpe?g|tiff|png|svg|webp)(\?[a-z0-9=]*)?$/i},files:{mimeTypes:["image/*"]}}}async onPaste(i){switch(i.type){case"tag":{const a=i.detail.data;if(/^blob:/.test(a.src)){const r=await(await fetch(a.src)).blob();this.uploadFile(r);break}this.uploadUrl(a.src);break}case"pattern":{const a=i.detail.data;this.uploadUrl(a);break}case"file":{const a=i.detail.file;this.uploadFile(a);break}}}set data(i){var a;this.image=i.file,this._data.caption=i.caption||"",this.ui.fillCaption(this._data.caption),P.tunes.forEach(({name:s})=>{const r=typeof i[s]<"u"&&(!0===i[s]||"true"===i[s]);this.setTune(s,r)}),(i.caption||!0===(null==(a=this.config.features)?void 0:a.caption))&&this.setTune("caption",!0)}get data(){return this._data}set image(i){this._data.file=i||{url:""},i&&i.url&&this.ui.fillImage(i.url)}onUpload(i){i.success&&i.file?this.image=i.file:this.uploadingFailed("incorrect response: "+JSON.stringify(i))}uploadingFailed(i){console.log("Image Tool: uploading failed because of",i),this.api.notifier.show({message:this.api.i18n.t("Couldn’t upload image. Please try another."),style:"error"}),this.ui.hidePreloader()}tuneToggled(i,a){"caption"===i?(this.ui.applyTune(i,a),0==a&&(this._data.caption="",this.ui.fillCaption(""))):this.setTune(i,a)}setTune(i,a){this._data[i]=a,this.ui.applyTune(i,a),"stretched"===i&&Promise.resolve().then(()=>{this.block.stretched=a}).catch(s=>{console.error(s)})}uploadFile(i){this.uploader.uploadByFile(i,{onPreview:a=>{this.ui.showPreloader(a)}})}uploadUrl(i){this.ui.showPreloader(i),this.uploader.uploadByUrl(i)}}class t{constructor(t={}){this.width=t.width??100,this.ratio=t.ratio??null,this.align=t.align??"center",this.rounded=t.rounded??0}isDefault(){return 100===this.width&&null===this.ratio&&"center"===this.align&&0===this.rounded}isDecorEnabled(){return this.rounded>0}reset(){this.width=100,this.ratio=null,this.align="center",this.rounded=0}cycleWidth(){const t=[100,80,60,50];this.width=t[(t.indexOf(this.width)+1)%t.length]}cycleRatio(){const t=[null,"16:9","4:3","1:1"],e=t.indexOf(this.ratio);this.ratio=t[(e+1)%t.length],null!==this.ratio&&(this.width=100)}cycleAlign(){const t=["left","center","right"];this.align=t[(t.indexOf(this.align)+1)%t.length]}cycleRounded(){const t=[0,8,16,24],e=t.indexOf(this.rounded);this.rounded=t[(e+1)%t.length]}toJSON(){return{width:this.width,ratio:this.ratio,align:this.align,rounded:this.rounded}}}const e={reset:'\n\n \n\n',width:{100:'\n\n W\n 100%\n\n',80:'\n\n W\n 80%\n\n',60:'\n\n W\n 60%\n\n',50:'\n\n W\n 50%\n\n'},ratio:{null:'\n\n \n\n',"16:9":'\n\n 16:9\n\n',"4:3":'\n\n 4:3\n\n',"1:1":'\n\n 1:1\n\n'},align:{left:'\n\n \n \n \n\n',center:'\n\n \n \n \n\n',right:'\n\n \n \n \n\n'},rounded:{0:'\n\n \n \n \n \n \n\n',8:'\n\n \n\n',16:'\n\n \n\n',24:'\n\n \n\n'}};class n{constructor(t,e,n){this.api=t,this.state=e,this.onChange=n,this.buttons=[]}render(){const t=document.createElement("div");return t.classList.add("image-tune-buttons"),this._add(t,"reset",()=>{this.state.reset(),this.update()}),this._add(t,"width",()=>{this.state.cycleWidth(),this.update()}),this._add(t,"ratio",()=>{this.state.cycleRatio(),this.update()}),this._add(t,"align",()=>{this.state.cycleAlign(),this.update()}),this._add(t,"rounded",()=>{this.state.cycleRounded(),this.update()}),this.update(),t}_add(t,e,n){const i=document.createElement("button");i.classList.add(this.api.styles.settingsButton),i.dataset.type=e,i.addEventListener("click",t=>{t.preventDefault(),t.stopPropagation(),n(),this.onChange()}),t.appendChild(i),this.buttons.push(i)}update(){this.buttons.forEach(t=>{switch(t.dataset.type){case"reset":t.innerHTML=e.reset,t.style.display=this.state.isDefault()?"none":"inline-flex";break;case"width":t.innerHTML=e.width[this.state.width];break;case"ratio":t.innerHTML=e.ratio[this.state.ratio];break;case"align":t.innerHTML=e.align[this.state.align];break;case"rounded":t.innerHTML=e.rounded[this.state.rounded]}})}}class i{static apply(t,e){const n=t.holder;if(!n)return;const i=n.querySelector(".image-tool");if(!i)return;i.classList.remove("mn-img--align-left","mn-img--align-center","mn-img--align-right","mn-img--ratio-16-9","mn-img--ratio-4-3","mn-img--ratio-1-1","mn-img--rounded-0","mn-img--rounded-8","mn-img--rounded-16","mn-img--rounded-24"),i.classList.add(`mn-img--align-${e.align}`),e.ratio&&i.classList.add(`mn-img--ratio-${e.ratio.replace(":","-")}`),i.classList.add(`mn-img--rounded-${e.rounded}`);const r=i.querySelector("img");r&&(r.style.maxWidth=`${e.width}%`,r.style.width="100%",r.style.height="auto")}}class r{static get isTune(){return!0}static get saveTuneId(){return"imageTunePlus"}constructor({api:e,data:i,block:r,config:o}){this.api=e,this.block=r,this.config=o||{};const s=r.data?.tunes?.imageTunePlus||i||{};this.state=new t(s),this.buttons=new n(e,this.state,()=>this._apply()),requestAnimationFrame(()=>this._apply(!1))}render(){const t=this.buttons.render();return requestAnimationFrame(()=>this._apply(!1)),t}save(){return this.state.toJSON()}_apply(t=!0){i.apply(this.block,this.state),t&&this.block.dispatchChange()}}!function o(t,e){void 0===e&&(e={});var n=e.insertAt;if("undefined"!=typeof document){var i=document.head||document.getElementsByTagName("head")[0],r=document.createElement("style");r.type="text/css","top"===n&&i.firstChild?i.insertBefore(r,i.firstChild):i.appendChild(r),r.styleSheet?r.styleSheet.cssText=t:r.appendChild(document.createTextNode(t))}}(".mn-img--align-left img{display:block;margin-left:0;margin-right:auto}.mn-img--align-center img{display:block;margin-left:auto;margin-right:auto}.mn-img--align-right img{display:block;margin-left:auto;margin-right:0}.mn-img--ratio-16-9 img{aspect-ratio:16/9}.mn-img--ratio-4-3 img{aspect-ratio:4/3}.mn-img--ratio-1-1 img{aspect-ratio:1/1}.mn-img--rounded-0 img{border-radius:0}.mn-img--rounded-8 img{border-radius:8px}.mn-img--rounded-16 img{border-radius:16px}.mn-img--rounded-24 img{border-radius:24px}");class ImageToolClickable extends P{render(){const wrapper=super.render();if(!wrapper.__longPressBound){wrapper.__longPressBound=!0;let pressTimer=null;const startPress=()=>{pressTimer=setTimeout(()=>{const blockId=this.block.id;window.Android?.onImageBlockClick&&window.Android.onImageBlockClick(blockId)},450)},cancelPress=()=>{clearTimeout(pressTimer)};wrapper.addEventListener("pointerdown",startPress),wrapper.addEventListener("pointerup",cancelPress),wrapper.addEventListener("pointerleave",cancelPress),wrapper.addEventListener("pointercancel",cancelPress)}return wrapper}}class SpacerTool{static get toolbox(){return{title:"Spacer",icon:"⸻"}}constructor({data:data}){const size=data?.size||"m";this.data={size:size}}render(){const el=document.createElement("div");return el.className=`ce-spacer ce-spacer-${this.data.size}`,el}save(){return{size:this.data.size||"m"}}validate(){return!0}static get isReadOnlySupported(){return!0}}window.EditorJS=Aa,window.Header=p,window.List=G,window.Paragraph=n$2,window.Delimiter=n$1,window.Marker=s$2,window.InlineCode=s$1,window.ImageTool=P,window.ImageTunePlus=r,window.ImageToolClickable=ImageToolClickable,window.SpacerTool=SpacerTool,function(global){global.initEditor=function initEditor(locale,i18n){global.__EDITOR_READY=!1,global.__saveTimer=null;const titleDiv=document.getElementById("noteTitleInput"),AttachesTool=global.AttachesTool,editor=new Aa({holder:"editorjs",i18n:i18n,placeholder:i18n.placeholder,autofocus:!0,readOnly:!1,tools:{paragraph:{class:n$2,inlineToolbar:!0},Headers:p,list:G,delimiter:n$1,marker:s$2,inlineCode:s$1,spacer:{class:SpacerTool},attaches:{class:AttachesTool,config:{uploader:{uploadByFile:file=>global.uploadAttachment(file)},openFile:file=>global.Android?.openAttachment(JSON.stringify(file))}},image:{class:ImageToolClickable,tunes:["imageTunePlus"],config:{features:{caption:!1},uploader:{uploadByFile:async file=>await window.uploadImage(file),uploadByUrl:async url=>({success:0})}}},imageTunePlus:{class:r}},onReady(){global.editor=editor,global.attachEditorInstance?.(editor),global.__EDITOR_READY=!0,document.getElementById("loading").style.display="none",document.getElementById("editorjs").style.display="block",global.Android?.onEditorReady(),updateTitlePlaceholder(i18n.title_placeholder||"Title...")},onChange(){global.__EDITOR_READY&&global.saveContent?.()}});function updateTitlePlaceholder(text){titleDiv.textContent.trim()?titleDiv.removeAttribute("data-placeholder"):titleDiv.setAttribute("data-placeholder",text)}return titleDiv.addEventListener("input",()=>{updateTitlePlaceholder(i18n.title_placeholder||"Title..."),global.Android?.onTitleChanged(titleDiv.innerText.trim())}),titleDiv.addEventListener("keydown",e=>{if("Enter"===e.key){e.preventDefault();const first=editor.blocks.getBlockByIndex(0),content=first?.holder.querySelector("[contenteditable='true']");content?.focus()}}),global.updateTitlePlaceholder=updateTitlePlaceholder,editor}}(window)}(); + */class P{constructor({data:i,config:a,api:s,readOnly:r,block:o}){this.isCaptionEnabled=null,this.api=s,this.block=o,this.config={endpoints:a.endpoints,additionalRequestData:a.additionalRequestData,additionalRequestHeaders:a.additionalRequestHeaders,field:a.field,types:a.types,captionPlaceholder:this.api.i18n.t(a.captionPlaceholder??"Caption"),buttonContent:a.buttonContent,uploader:a.uploader,actions:a.actions,features:a.features||{}},this.uploader=new A({config:this.config,onUpload:e=>this.onUpload(e),onError:e=>this.uploadingFailed(e)}),this.ui=new D({api:s,config:this.config,onSelectFile:()=>{this.uploader.uploadSelectedFile({onPreview:e=>{this.ui.showPreloader(e)}})},readOnly:r}),this._data={caption:"",withBorder:!1,withBackground:!1,stretched:!1,file:{url:""}},this.data=i}static get isReadOnlySupported(){return!0}static get toolbox(){return{icon:L,title:"Image"}}static get tunes(){return[{name:"withBorder",icon:'',title:"With border",toggle:!0},{name:"stretched",icon:'',title:"Stretch image",toggle:!0},{name:"withBackground",icon:'',title:"With background",toggle:!0}]}render(){var i,a,s;return(!0===(null==(i=this.config.features)?void 0:i.caption)||void 0===(null==(a=this.config.features)?void 0:a.caption)||"optional"===(null==(s=this.config.features)?void 0:s.caption)&&this.data.caption)&&(this.isCaptionEnabled=!0,this.ui.applyTune("caption",!0)),this.ui.render()}validate(i){return!!i.file.url}save(){const i=this.ui.nodes.caption;return this._data.caption=i.innerHTML,this.data}renderSettings(){var o;const i=P.tunes.concat(this.config.actions||[]),a={border:"withBorder",background:"withBackground",stretch:"stretched",caption:"caption"};"optional"===(null==(o=this.config.features)?void 0:o.caption)&&i.push({name:"caption",icon:'',title:"With caption",toggle:!0});const s=i.filter(e=>{var v,l;const d=Object.keys(a).find(f=>a[f]===e.name);return"caption"===d?!1!==(null==(v=this.config.features)?void 0:v.caption):null==d||!1!==(null==(l=this.config.features)?void 0:l[d])}),r=e=>{let d=this.data[e.name];return"caption"===e.name&&(d=this.isCaptionEnabled??d),d};return s.map(e=>({icon:e.icon,label:this.api.i18n.t(e.title),name:e.name,toggle:e.toggle,isActive:r(e),onActivate:()=>{if("function"==typeof e.action)return void e.action(e.name);let d=!r(e);"caption"===e.name&&(this.isCaptionEnabled=!this.isCaptionEnabled,d=this.isCaptionEnabled),this.tuneToggled(e.name,d)}}))}appendCallback(){this.ui.nodes.fileButton.click()}static get pasteConfig(){return{tags:[{img:{src:!0}}],patterns:{image:/https?:\/\/\S+\.(gif|jpe?g|tiff|png|svg|webp)(\?[a-z0-9=]*)?$/i},files:{mimeTypes:["image/*"]}}}async onPaste(i){switch(i.type){case"tag":{const a=i.detail.data;if(/^blob:/.test(a.src)){const r=await(await fetch(a.src)).blob();this.uploadFile(r);break}this.uploadUrl(a.src);break}case"pattern":{const a=i.detail.data;this.uploadUrl(a);break}case"file":{const a=i.detail.file;this.uploadFile(a);break}}}set data(i){var a;this.image=i.file,this._data.caption=i.caption||"",this.ui.fillCaption(this._data.caption),P.tunes.forEach(({name:s})=>{const r=typeof i[s]<"u"&&(!0===i[s]||"true"===i[s]);this.setTune(s,r)}),(i.caption||!0===(null==(a=this.config.features)?void 0:a.caption))&&this.setTune("caption",!0)}get data(){return this._data}set image(i){this._data.file=i||{url:""},i&&i.url&&this.ui.fillImage(i.url)}onUpload(i){i.success&&i.file?this.image=i.file:this.uploadingFailed("incorrect response: "+JSON.stringify(i))}uploadingFailed(i){console.log("Image Tool: uploading failed because of",i),this.api.notifier.show({message:this.api.i18n.t("Couldn’t upload image. Please try another."),style:"error"}),this.ui.hidePreloader()}tuneToggled(i,a){"caption"===i?(this.ui.applyTune(i,a),0==a&&(this._data.caption="",this.ui.fillCaption(""))):this.setTune(i,a)}setTune(i,a){this._data[i]=a,this.ui.applyTune(i,a),"stretched"===i&&Promise.resolve().then(()=>{this.block.stretched=a}).catch(s=>{console.error(s)})}uploadFile(i){this.uploader.uploadByFile(i,{onPreview:a=>{this.ui.showPreloader(a)}})}uploadUrl(i){this.ui.showPreloader(i),this.uploader.uploadByUrl(i)}}class t{constructor(t={}){this.width=t.width??100,this.ratio=t.ratio??null,this.align=t.align??"center",this.rounded=t.rounded??0}isDefault(){return 100===this.width&&null===this.ratio&&"center"===this.align&&0===this.rounded}isDecorEnabled(){return this.rounded>0}reset(){this.width=100,this.ratio=null,this.align="center",this.rounded=0}cycleWidth(){const t=[100,80,60,50];this.width=t[(t.indexOf(this.width)+1)%t.length]}cycleRatio(){const t=[null,"16:9","4:3","1:1"],e=t.indexOf(this.ratio);this.ratio=t[(e+1)%t.length],null!==this.ratio&&(this.width=100)}cycleAlign(){const t=["left","center","right"];this.align=t[(t.indexOf(this.align)+1)%t.length]}cycleRounded(){const t=[0,8,16,24],e=t.indexOf(this.rounded);this.rounded=t[(e+1)%t.length]}toJSON(){return{width:this.width,ratio:this.ratio,align:this.align,rounded:this.rounded}}}const e={reset:'\n\n \n\n',width:{100:'\n\n W\n 100%\n\n',80:'\n\n W\n 80%\n\n',60:'\n\n W\n 60%\n\n',50:'\n\n W\n 50%\n\n'},ratio:{null:'\n\n \n\n',"16:9":'\n\n 16:9\n\n',"4:3":'\n\n 4:3\n\n',"1:1":'\n\n 1:1\n\n'},align:{left:'\n\n \n \n \n\n',center:'\n\n \n \n \n\n',right:'\n\n \n \n \n\n'},rounded:{0:'\n\n \n \n \n \n \n\n',8:'\n\n \n\n',16:'\n\n \n\n',24:'\n\n \n\n'}};class n{constructor(t,e,n){this.api=t,this.state=e,this.onChange=n,this.buttons=[]}render(){const t=document.createElement("div");return t.classList.add("image-tune-buttons"),this._add(t,"reset",()=>{this.state.reset(),this.update()}),this._add(t,"width",()=>{this.state.cycleWidth(),this.update()}),this._add(t,"ratio",()=>{this.state.cycleRatio(),this.update()}),this._add(t,"align",()=>{this.state.cycleAlign(),this.update()}),this._add(t,"rounded",()=>{this.state.cycleRounded(),this.update()}),this.update(),t}_add(t,e,n){const i=document.createElement("button");i.classList.add(this.api.styles.settingsButton),i.dataset.type=e,i.addEventListener("click",t=>{t.preventDefault(),t.stopPropagation(),n(),this.onChange()}),t.appendChild(i),this.buttons.push(i)}update(){this.buttons.forEach(t=>{switch(t.dataset.type){case"reset":t.innerHTML=e.reset,t.style.display=this.state.isDefault()?"none":"inline-flex";break;case"width":t.innerHTML=e.width[this.state.width];break;case"ratio":t.innerHTML=e.ratio[this.state.ratio];break;case"align":t.innerHTML=e.align[this.state.align];break;case"rounded":t.innerHTML=e.rounded[this.state.rounded]}})}}class i{static apply(t,e){const n=t.holder;if(!n)return;const i=n.querySelector(".image-tool");if(!i)return;i.classList.remove("mn-img--align-left","mn-img--align-center","mn-img--align-right","mn-img--ratio-16-9","mn-img--ratio-4-3","mn-img--ratio-1-1","mn-img--rounded-0","mn-img--rounded-8","mn-img--rounded-16","mn-img--rounded-24"),i.classList.add(`mn-img--align-${e.align}`),e.ratio&&i.classList.add(`mn-img--ratio-${e.ratio.replace(":","-")}`),i.classList.add(`mn-img--rounded-${e.rounded}`);const r=i.querySelector("img");r&&(r.style.maxWidth=`${e.width}%`,r.style.width="100%",r.style.height="auto")}}class r{static get isTune(){return!0}static get saveTuneId(){return"imageTunePlus"}constructor({api:e,data:i,block:r,config:o}){this.api=e,this.block=r,this.config=o||{};const s=r.data?.tunes?.imageTunePlus||i||{};this.state=new t(s),this.buttons=new n(e,this.state,()=>this._apply()),requestAnimationFrame(()=>this._apply(!1))}render(){const t=this.buttons.render();return requestAnimationFrame(()=>this._apply(!1)),t}save(){return this.state.toJSON()}_apply(t=!0){i.apply(this.block,this.state),t&&this.block.dispatchChange()}}!function o(t,e){void 0===e&&(e={});var n=e.insertAt;if("undefined"!=typeof document){var i=document.head||document.getElementsByTagName("head")[0],r=document.createElement("style");r.type="text/css","top"===n&&i.firstChild?i.insertBefore(r,i.firstChild):i.appendChild(r),r.styleSheet?r.styleSheet.cssText=t:r.appendChild(document.createTextNode(t))}}(".mn-img--align-left img{display:block;margin-left:0;margin-right:auto}.mn-img--align-center img{display:block;margin-left:auto;margin-right:auto}.mn-img--align-right img{display:block;margin-left:auto;margin-right:0}.mn-img--ratio-16-9 img{aspect-ratio:16/9}.mn-img--ratio-4-3 img{aspect-ratio:4/3}.mn-img--ratio-1-1 img{aspect-ratio:1/1}.mn-img--rounded-0 img{border-radius:0}.mn-img--rounded-8 img{border-radius:8px}.mn-img--rounded-16 img{border-radius:16px}.mn-img--rounded-24 img{border-radius:24px}");class ImageToolClickable extends P{constructor(params){super(params);const showPreloader=this.ui.showPreloader.bind(this.ui);this.ui.showPreloader=src=>{showPreloader(src),this.reserveFromPreview(src)}}reserveFromPreview(src){if(!src)return;const probe=new Image;probe.onload=()=>{const wrapper=this.ui?.nodes?.wrapper;wrapper&&!wrapper.classList.contains("image-tool--reserved")&&this.reserveSpace({width:probe.naturalWidth,height:probe.naturalHeight})},probe.src=src}render(){const wrapper=super.render();if(this.reserveSpace(this.data?.file),!wrapper.__longPressBound){wrapper.__longPressBound=!0;let pressTimer=null;const startPress=()=>{pressTimer=setTimeout(()=>{const blockId=this.block.id;window.Android?.onImageBlockClick&&window.Android.onImageBlockClick(blockId)},450)},cancelPress=()=>{clearTimeout(pressTimer)};wrapper.addEventListener("pointerdown",startPress),wrapper.addEventListener("pointerup",cancelPress),wrapper.addEventListener("pointerleave",cancelPress),wrapper.addEventListener("pointercancel",cancelPress)}return wrapper}onUpload(response){response?.success&&response.file&&this.reserveSpace(response.file),super.onUpload(response)}reserveSpace(file){const width=Number(file?.width),height=Number(file?.height),wrapper=this.ui?.nodes?.wrapper;wrapper&&width>0&&height>0&&(wrapper.style.setProperty("--reserved-ratio",`${width} / ${height}`),wrapper.classList.add("image-tool--reserved"))}}class SpacerTool{static get toolbox(){return{title:"Spacer",icon:"⸻"}}constructor({data:data}){const size=data?.size||"m";this.data={size:size}}render(){const el=document.createElement("div");return el.className=`ce-spacer ce-spacer-${this.data.size}`,el}save(){return{size:this.data.size||"m"}}validate(){return!0}static get isReadOnlySupported(){return!0}}window.EditorJS=Aa,window.Header=p,window.List=G,window.Paragraph=n$2,window.Delimiter=n$1,window.Marker=s$2,window.InlineCode=s$1,window.ImageTool=P,window.ImageTunePlus=r,window.ImageToolClickable=ImageToolClickable,window.SpacerTool=SpacerTool,function(global){global.initEditor=function initEditor(locale,i18n){global.__EDITOR_READY=!1,global.__saveTimer=null;const titleDiv=document.getElementById("noteTitleInput");global.__titlePlaceholder=i18n.title_placeholder;const AttachesTool=global.AttachesTool,params=new URLSearchParams(global.location.search),editor=new Aa({holder:"editorjs",i18n:i18n,placeholder:i18n.placeholder,autofocus:"0"!==params.get("autofocus"),readOnly:"1"===params.get("readonly"),tools:{paragraph:{class:n$2,inlineToolbar:!0},Headers:p,list:G,delimiter:n$1,marker:s$2,inlineCode:s$1,spacer:{class:SpacerTool},attaches:{class:AttachesTool,config:{uploader:{uploadByFile:file=>global.uploadAttachment(file)},openFile:file=>global.Android?.openAttachment(JSON.stringify(file))}},image:{class:ImageToolClickable,tunes:["imageTunePlus"],config:{features:{caption:!1},uploader:{uploadByFile:async file=>await window.uploadImage(file),uploadByUrl:async url=>({success:0})}}},imageTunePlus:{class:r}},onReady(){global.editor=editor,global.attachEditorInstance?.(editor),global.__EDITOR_READY=!0,document.getElementById("loading").style.display="none",document.getElementById("editorjs").style.display="block",global.Android?.onEditorReady(),updateTitlePlaceholder(global.__titlePlaceholder)},onChange(api,event){global.__EDITOR_READY&&(global.saveContent?.(),global.historyEditorChanged?.(event))}});function updateTitlePlaceholder(text){titleDiv.textContent.trim()?titleDiv.removeAttribute("data-placeholder"):titleDiv.setAttribute("data-placeholder",text)}return titleDiv.addEventListener("input",()=>{updateTitlePlaceholder(global.__titlePlaceholder),global.Android?.onTitleChanged(titleDiv.innerText.trim())}),titleDiv.addEventListener("keydown",e=>{if("Enter"===e.key){e.preventDefault();const first=editor.blocks.getBlockByIndex(0),content=first?.holder.querySelector("[contenteditable='true']");content?.focus()}}),global.updateTitlePlaceholder=updateTitlePlaceholder,editor}}(window)}(); diff --git a/app/src/main/assets/editor/js/locales.js b/app/src/main/assets/editor/js/locales.js index ac954329..d0056138 100644 --- a/app/src/main/assets/editor/js/locales.js +++ b/app/src/main/assets/editor/js/locales.js @@ -110,6 +110,169 @@ window.EditorLocales = { } } }, + + be: { + placeholder: 'Пачніце пісаць...', + title_placeholder: 'Назва', + messages: { + ui: { + blockTunes: { + toggler: { + 'Click to tune': 'Націсніце, каб наладзіць', + 'or drag to move': 'або перацягніце' + } + }, + inlineToolbar: { converter: { 'Convert to': 'Пераўтварыць у' } }, + toolbar: { toolbox: { Add: 'Дадаць' } }, + popover: { + Filter: 'Пошук', + 'Nothing found': 'Нічога не знойдзена', + 'Convert to': 'Пераўтварыць у' + } + }, + toolNames: { + Text: 'Абзац', + Heading: 'Загаловак', + 'Ordered List': 'Нумараваны спіс', + 'Unordered List': 'Маркіраваны спіс', + Warning: 'Папярэджанне', + Checklist: 'Спіс задач', + Quote: 'Цытата', + Code: 'Код', + Delimiter: 'Раздзяляльнік', + 'Raw HTML': 'HTML-фрагмент', + Table: 'Табліца', + Link: 'Спасылка', + Marker: 'Маркер', + Bold: 'Тоўсты', + Italic: 'Курсіў', + InlineCode: 'Монашырынны', + Image: 'Выява' + }, + tools: { + paragraph: { 'Enter something': 'Увядзіце тэкст' }, + link: { 'Add a link': 'Дадайце спасылку' }, + header: { 'Heading 1': 'Загаловак 1', 'Heading 2': 'Загаловак 2' } + }, + blockTunes: { + delete: { + Delete: 'Выдаліць', + 'Click to delete': 'Пацвердзіць выдаленне' + }, + moveUp: { 'Move up': 'Уверх' }, + moveDown: { 'Move down': 'Уніз' } + } + } + }, + + it: { + placeholder: 'Inizia a scrivere...', + title_placeholder: 'Titolo', + messages: { + ui: { + blockTunes: { + toggler: { + 'Click to tune': 'Tocca per modificare', + 'or drag to move': 'o trascina per spostare' + } + }, + inlineToolbar: { converter: { 'Convert to': 'Converti in' } }, + toolbar: { toolbox: { Add: 'Aggiungi' } }, + popover: { + Filter: 'Cerca', + 'Nothing found': 'Nessun risultato', + 'Convert to': 'Converti in' + } + }, + toolNames: { + Text: 'Paragrafo', + Heading: 'Intestazione', + 'Ordered List': 'Elenco numerato', + 'Unordered List': 'Elenco puntato', + Warning: 'Avviso', + Checklist: 'Lista di controllo', + Quote: 'Citazione', + Code: 'Codice', + Delimiter: 'Separatore', + 'Raw HTML': 'HTML', + Table: 'Tabella', + Link: 'Link', + Marker: 'Evidenziatore', + Bold: 'Grassetto', + Italic: 'Corsivo', + InlineCode: 'Monospaziato', + Image: 'Immagine' + }, + tools: { + paragraph: { 'Enter something': 'Inserisci il testo' }, + link: { 'Add a link': 'Aggiungi un link' }, + header: { 'Heading 1': 'Intestazione 1', 'Heading 2': 'Intestazione 2' } + }, + blockTunes: { + delete: { + Delete: 'Elimina', + 'Click to delete': 'Conferma eliminazione' + }, + moveUp: { 'Move up': 'Sposta su' }, + moveDown: { 'Move down': 'Sposta giù' } + } + } + }, + + kk: { + placeholder: 'Жаза бастаңыз...', + title_placeholder: 'Атауы', + messages: { + ui: { + blockTunes: { + toggler: { + 'Click to tune': 'Баптау үшін басыңыз', + 'or drag to move': 'немесе жылжыту үшін сүйреңіз' + } + }, + inlineToolbar: { converter: { 'Convert to': 'Түрлендіру' } }, + toolbar: { toolbox: { Add: 'Қосу' } }, + popover: { + Filter: 'Іздеу', + 'Nothing found': 'Ештеңе табылмады', + 'Convert to': 'Түрлендіру' + } + }, + toolNames: { + Text: 'Абзац', + Heading: 'Тақырып', + 'Ordered List': 'Нөмірленген тізім', + 'Unordered List': 'Таңбаланған тізім', + Warning: 'Ескерту', + Checklist: 'Тапсырмалар тізімі', + Quote: 'Дәйексөз', + Code: 'Код', + Delimiter: 'Бөлгіш', + 'Raw HTML': 'HTML үзіндісі', + Table: 'Кесте', + Link: 'Сілтеме', + Marker: 'Маркер', + Bold: 'Қалың', + Italic: 'Көлбеу', + InlineCode: 'Бірдей енді', + Image: 'Сурет' + }, + tools: { + paragraph: { 'Enter something': 'Мәтін енгізіңіз' }, + link: { 'Add a link': 'Сілтеме қосыңыз' }, + header: { 'Heading 1': 'Тақырып 1', 'Heading 2': 'Тақырып 2' } + }, + blockTunes: { + delete: { + Delete: 'Жою', + 'Click to delete': 'Жоюды растау' + }, + moveUp: { 'Move up': 'Жоғары' }, + moveDown: { 'Move down': 'Төмен' } + } + } + }, + ru: { placeholder: 'Начните писать...', title_placeholder: 'Заголовок', diff --git a/app/src/main/assets/editor/js/runtime.min.js b/app/src/main/assets/editor/js/runtime.min.js index f0e04f45..8af8f0c0 100644 --- a/app/src/main/assets/editor/js/runtime.min.js +++ b/app/src/main/assets/editor/js/runtime.min.js @@ -2,4 +2,4 @@ * My Notes — Editor Runtime Build * (c) 2025 pasichDev. Editor.js — MIT */ -!function(){"use strict";let e=null,n=!1,t=null;function safeAndroidCall(e,...n){if(window.Android&&"function"==typeof window.Android[e])try{return window.Android[e](...n)}catch(n){console.error(`[AndroidBridge] ${e} failed:`,n)}return null}function fileToBase64(e){return new Promise((n,t)=>{const o=new FileReader;o.onload=()=>n(o.result),o.onerror=t,o.readAsDataURL(e)})}function updateTitlePlaceholder(){const e=document.getElementById("noteTitleInput");e.innerText.trim()?e.removeAttribute("data-placeholder"):e.setAttribute("data-placeholder","Title...")}window.attachEditorInstance=function(n){e=n,window.__EDITOR_READY=!0},window.deleteAttachmentBlockFromAndroid=function(n,t){if(e)try{const o=e.blocks.getById(n);if(!o)return;const r=o.holder,l=[...r.parentNode.children].indexOf(r);if(l<0)return;e.blocks.delete(l),setTimeout(()=>{safeAndroidCall("onAttachmentBlockDeletedResponse",n,t)},30)}catch(e){console.error("[Delete] JS ERROR:",e)}};const o=document.getElementById("noteTitleInput");o.addEventListener("input",()=>{updateTitlePlaceholder(),safeAndroidCall("onTitleChanged",o.innerText.trim())}),window.setThemeColors=function(e){if(!e)return;const n=document.documentElement;for(const t in e)n.style.setProperty(`--${t}`,e[t])},window.loadNote=function(n){if(!e)return;document.getElementById("noteTitleInput").innerText=n.title||"",updateTitlePlaceholder();let o=[];n.plainTextFallback&&n.plainText?o=[{type:"paragraph",data:{text:n.plainText.replace(/\n/g,"
")}}]:n.valueJson&&(o=n.valueJson),e.render({blocks:o}).then(()=>{t=JSON.stringify(o)})},window.uploadAttachment=async function(e){const n=safeAndroidCall("uploadFile",await fileToBase64(e),e.name);return{success:n?1:0,file:n?{url:n,name:e.name,size:e.size,extension:e.name.split(".").pop()}:null}},window.toggleReadModeFromAndroid=function(){n=!n,e?.readOnly&&e.readOnly.toggle()},window.saveContent=function(){e&&e.save().then(e=>{const n=e.blocks,o=JSON.stringify(n);o!==t&&(t=o,safeAndroidCall("onContentChanged",o))}).catch(e=>console.error("[Editor] Save failed:",e))},window.uploadImage=async function(e){const n=safeAndroidCall("uploadImage",await fileToBase64(e),e.name);if(!n)return{success:0};try{return JSON.parse(n)}catch(e){return console.error("[ImageUpload] Invalid JSON:",n),{success:0}}}}(); +!function(){"use strict";let e=null,t=!1,n=null;function safeAndroidCall(e,...t){if(window.Android&&"function"==typeof window.Android[e])try{return window.Android[e](...t)}catch(t){console.error(`[AndroidBridge] ${e} failed:`,t)}return null}window.attachEditorInstance=function(n){e=n,window.__EDITOR_READY=!0,t=!!n.readOnly?.isEnabled,applyTitleEditable()};const o="1"===new URLSearchParams(window.location.search).get("dbltap");function saveContent(){e&&e.save().then(e=>{const t=e.blocks,o=JSON.stringify(t);o!==n&&(n=o,safeAndroidCall("onContentChanged",o))}).catch(e=>console.error("[Editor] Save failed:",e))}function flushContent(){return e?e.save().then(e=>{const t=JSON.stringify(e.blocks);t!==n&&(n=t,safeAndroidCall("onContentFlushed",t))}).catch(e=>console.error("[Editor] Flush failed:",e)):Promise.resolve()}let r=null;function reportViewportAnchor(){if(!e)return;if(window.scrollY<1)return void safeAndroidCall("onViewportAnchor",-1,0);const t=e.blocks.getBlocksCount();for(let n=0;n0)return void safeAndroidCall("onViewportAnchor",n,Math.round(o.top))}}window.addEventListener("scroll",()=>{clearTimeout(r),r=setTimeout(reportViewportAnchor,200)},{passive:!0});let i=null,l=!1;function blockForHolder(t){const n=e.blocks.getBlocksCount();for(let o=0;o0?Math.min(1,window.scrollY/t):0,topId:null,topIndex:-1,topOffset:0,caret:caretPosition()};if(window.scrollY>=1){const t=e.blocks.getBlocksCount();for(let o=0;o0){n.topId=t.id,n.topIndex=o,n.topOffset=Math.round(r.top);break}}}return n}function reportViewState(){e&&l&&safeAndroidCall("onViewState",JSON.stringify(currentViewState()))}let c=0;function reportViewStateNow(){clearTimeout(i),c=Date.now(),reportViewState()}function scheduleViewStateReport(){clearTimeout(i),Date.now()-c>=250&&(c=Date.now(),reportViewState()),i=setTimeout(reportViewStateNow,200)}function placeCaret(e,t){const n=document.createTreeWalker(e,NodeFilter.SHOW_TEXT);let o=Math.max(0,t||0),r=n.nextNode(),i=null;for(;r&&(i=r,!(o<=r.length));)o-=r.length,r=n.nextNode();const l=document.createRange();r?l.setStart(r,o):i?l.setStart(i,i.length):l.setStart(e,0),l.collapse(!0);const c=window.getSelection();c.removeAllRanges(),c.addRange(l)}function restoreViewState(n){if(!e||!n)return;if(n.caretId&&!t){const t=e.blocks.getById(n.caretId);if(t?.holder){try{e.caret.setToBlock(t,"start")}catch(e){console.error("[Editor] setToBlock failed:",e)}const o=editableInputs(t.holder),r=o[Math.min(n.caretInput||0,o.length-1)];r&&(r.focus({preventScroll:!0}),placeCaret(r,n.caretOffset))}}let o=n.topId?e.blocks.getById(n.topId):null;if(!o&&n.topIndex>=0){const t=e.blocks.getBlocksCount();t>0&&(o=e.blocks.getBlockByIndex(Math.min(n.topIndex,t-1)))}o?.holder&&requestAnimationFrame(()=>{const e=o.holder.getBoundingClientRect().top;window.scrollTo(0,window.scrollY+e-(n.topOffset||0))})}function keepCaretVisible(){const e=window.getSelection();if(!e||0===e.rangeCount)return;const t=e.focusNode,n=t&&(1===t.nodeType?t:t.parentElement);if(!n||!n.isContentEditable)return;const o=e.getRangeAt(0).cloneRange();o.collapse(!1);let r=o.getClientRects()[0];(!r||0===r.top&&0===r.bottom)&&(r=n.getBoundingClientRect());const i=window.innerHeight;r.bottom>i-24?window.scrollBy(0,r.bottom-i+24):r.top<24&&window.scrollBy(0,r.top-24)}window.addEventListener("scroll",scheduleViewStateReport,{passive:!0}),document.addEventListener("selectionchange",scheduleViewStateReport),document.addEventListener("visibilitychange",()=>{document.hidden&&reportViewStateNow()}),window.addEventListener("resize",()=>requestAnimationFrame(keepCaretVisible));const s=100,a={undo:[],redo:[],current:null,pending:!1,batchHasText:!1,caretBefore:null,lastCaret:null,batchStart:0,timer:null,applying:!1,settleUntil:0,epoch:0,changeSeq:0,queue:Promise.resolve(),reported:""};function historyTitleElement(){return document.getElementById("noteTitleInput")}function historyCaret(){if(!e||t)return null;const n=window.getSelection();if(!n||0===n.rangeCount||!n.focusNode)return null;const o=historyTitleElement();if(o&&o.contains(n.focusNode)){const e=document.createRange();return e.selectNodeContents(o),e.setEnd(n.focusNode,n.focusOffset),{title:!0,offset:e.toString().length}}return caretPosition()}function readDocument(){return e.save().then(e=>({title:historyTitleElement()?.innerText||"",blocks:e.blocks}))}function historyEntry(e,t){return{doc:e,json:JSON.stringify(e),caret:t}}function reportHistory(){const e=a.undo.length>0||a.pending,t=a.redo.length>0&&!a.pending,n=`${e}|${t}`;n!==a.reported&&(a.reported=n,safeAndroidCall("onHistoryChanged",e,t))}function attachmentUrls(e){const t=new Set;for(const n of e?.blocks||[]){const e=n?.data;if(e?.file?.url&&t.add(e.file.url),Array.isArray(e?.files))for(const n of e.files)n?.url&&t.add(n.url)}return t}function keepAttachmentsForUndo(e,t){const n=attachmentUrls(t),o=[...attachmentUrls(e)].filter(e=>!n.has(e));o.length>0&&safeAndroidCall("keepForUndo",JSON.stringify(o))}function stopHistory(){a.epoch++,clearTimeout(a.timer),a.timer=null,a.undo=[],a.redo=[],a.current=null,a.pending=!1,a.caretBefore=null,a.lastCaret=null,a.applying=!1,a.settleUntil=0,reportHistory()}const d=15e5;let u=null;function exportedEntry(e){return{json:e.json,caret:e.caret||null}}function importedEntry(e){return historyEntry(JSON.parse(e.json),e.caret||null)}function resetHistory(n){if(stopHistory(),!e||t)return;const o=a.epoch;readDocument().then(e=>{o===a.epoch&&(a.current=historyEntry(e,null),n&&(a.settleUntil=Date.now()+600),function(){const e=u;if(u=null,e?.current&&e.current.json===a.current?.json)try{a.undo=(e.undo||[]).map(importedEntry).slice(-s),a.redo=(e.redo||[]).map(importedEntry).slice(-s),a.current.caret=e.current.caret||null}catch(e){console.error("[History] import failed:",e),a.undo=[],a.redo=[]}}(),reportHistory())}).catch(e=>console.error("[History] reset failed:",e))}function historyNoteChange(e,n){if(!a.current||a.applying||t)return;if(n&&Date.now()=3e3;a.timer=setTimeout(captureHistory,e||o?0:700),reportHistory()}function historyInputStarting(){!a.current||a.applying||t||a.pending||a.caretBefore||(a.caretBefore=historyCaret())}function captureHistory(t){if(clearTimeout(a.timer),a.timer=null,!e||!a.current||!a.pending&&!t)return Promise.resolve();const n=a.epoch,o=a.changeSeq;return readDocument().then(e=>{if(n!==a.epoch||!a.current)return;const t=a.caretBefore,r=o!==a.changeSeq;a.pending=r,a.caretBefore=null,r?a.batchStart=Date.now():a.batchHasText=!1;const i=historyEntry(e,historyCaret());i.json!==a.current.json&&(keepAttachmentsForUndo(a.current.doc,e),a.undo.push({...a.current,caret:t||a.current.caret}),a.undo.length>s&&a.undo.shift(),a.redo=[],a.current=i),reportHistory()})}function findBlock(t){if(!t)return null;const n=e.blocks.getBlocksCount();for(let o=0;oreadDocument()).then(n=>{r===a.epoch&&(a.current=historyEntry(n,t.caret),a.settleUntil=Date.now()+600,function(t,n,o){const r=historyTitleElement();if(t?.title&&r)r.focus({preventScroll:!0}),placeCaret(r,t.offset);else if(t?.id&&findBlock(t.id))restoreViewState({caretId:t.id,caretInput:t.input,caretOffset:t.offset,topId:findBlock(n?.topId)?n.topId:null,topIndex:n?n.topIndex:-1,topOffset:n?.topOffset||0});else{const t=findBlock(o);if(t)try{e.caret.setToBlock(t,"end")}catch(e){console.error("[History] placing the caret failed:",e)}n&&restoreViewState({topId:findBlock(n.topId)?n.topId:null,topIndex:n.topIndex,topOffset:n.topOffset})}requestAnimationFrame(()=>requestAnimationFrame(keepCaretVisible))}(t.caret,c,s),saveContent())}).catch(e=>console.error("[History] applying a step failed:",e)).finally(()=>{r===a.epoch&&(a.applying=!1),reportHistory()})}function runHistoryAction(e){return a.queue=a.queue.then(e).catch(e=>console.error("[History]",e)),a.queue}function historyUndo(){e&&!t&&runHistoryAction(()=>captureHistory(!0).then(()=>{const e=a.undo.pop();if(e&&a.current)return a.redo.push({...a.current,caret:historyCaret()||a.current.caret}),applyHistoryEntry(e)}))}function historyRedo(){e&&!t&&runHistoryAction(()=>captureHistory(!0).then(()=>{const e=a.redo.pop();if(e&&a.current)return a.undo.push({...a.current,caret:historyCaret()||a.current.caret}),applyHistoryEntry(e)}))}document.addEventListener("keydown",e=>{const t=function(e){if(!e.ctrlKey&&!e.metaKey||e.altKey)return null;const t=(e.key||"").toLowerCase();return"z"===t?e.shiftKey?"redo":"undo":"y"!==t||e.shiftKey?null:"redo"}(e);if(t)return e.preventDefault(),e.stopPropagation(),void("undo"===t?historyUndo():historyRedo());(function(e){if(e.ctrlKey||e.metaKey||e.altKey)return!1;if(229===e.keyCode)return!0;const t=e.key||"";return 1===t.length||"Enter"===t||"Backspace"===t||"Delete"===t||"Tab"===t})(e)&&(historyInputStarting(),"Enter"===e.key&&historyNoteChange(!0,!1))},!0),document.addEventListener("beforeinput",e=>{if("historyUndo"===e.inputType||"historyRedo"===e.inputType)return e.preventDefault(),void("historyUndo"===e.inputType?historyUndo():historyRedo());historyInputStarting();const t="string"==typeof e.data?e.data:"";historyNoteChange("insertParagraph"===e.inputType||"insertLineBreak"===e.inputType||/\s$/.test(t)&&(a.batchHasText||/\S/.test(t)),!1),/\S/.test(t)&&(a.batchHasText=!0)},!0),document.addEventListener("selectionchange",()=>{a.pending||a.applying||(a.lastCaret=historyCaret())});const f=new Map;let p=0;function requestUpload(e,t){return new Promise(n=>{const o=`u${Date.now()}_${++p}`;f.set(o,n);!0!==safeAndroidCall("requestPickedUpload",o,e,t.name||"",t.size||0)&&function(e){return new Promise((t,n)=>{const o=new FileReader;o.onload=()=>t(o.result),o.onerror=n,o.readAsDataURL(e)})}(t).then(n=>{!0!==safeAndroidCall("uploadBase64Async",o,e,n,t.name||"file")&&finishUpload(o,null)}).catch(()=>finishUpload(o,null))})}function finishUpload(e,t){const n=f.get(e);n&&(f.delete(e),n(t))}function updateTitlePlaceholder(){const e=document.getElementById("noteTitleInput");e.innerText.trim()?e.removeAttribute("data-placeholder"):e.setAttribute("data-placeholder",window.__titlePlaceholder||"")}window.__onUploadFinished=finishUpload,window.deleteAttachmentBlockFromAndroid=function(t,n){if(e)try{const o=e.blocks.getById(t);if(!o)return;const r=o.holder,i=[...r.parentNode.children].indexOf(r);if(i<0)return;e.blocks.delete(i),function(e){const strip=t=>{const n=t.doc.blocks.filter(t=>t.id!==e);return n.length===t.doc.blocks.length?t:historyEntry({title:t.doc.title,blocks:n},t.caret)},distinct=e=>e.filter((t,n)=>0===n||t.json!==e[n-1].json);a.current&&(a.current=strip(a.current));const t=distinct(a.undo.map(strip));a.current&&t.length&&t[t.length-1].json===a.current.json&&t.pop();const n=distinct(a.redo.map(strip).reverse());a.current&&n.length&&n[0].json===a.current.json&&n.shift(),a.undo=t,a.redo=n.reverse(),reportHistory()}(t),setTimeout(()=>{safeAndroidCall("onAttachmentBlockDeletedResponse",t,n)},30)}catch(e){console.error("[Delete] JS ERROR:",e)}};const h=document.getElementById("noteTitleInput");function applyTitleEditable(){const e=document.getElementById("noteTitleInput");e&&(e.setAttribute("contenteditable",t?"false":"true"),t&&document.activeElement===e&&e.blur())}function setReadMode(n,o){if(!e?.readOnly)return Promise.resolve(t);const r=currentViewState();return t=n,applyTitleEditable(),e.readOnly.toggle(n).then(()=>(n||a.current||resetHistory(!0),restoreViewState({caretId:!n&&o?o.id:null,caretInput:o?o.input:0,caretOffset:o?o.offset:0,topId:r.topId,topIndex:r.topIndex,topOffset:r.topOffset}),safeAndroidCall("onReadModeChanged",n,!!o),n))}h.addEventListener("input",()=>{updateTitlePlaceholder(),safeAndroidCall("onTitleChanged",h.innerText.trim()),historyNoteChange(!1,!1)}),document.addEventListener("dblclick",n=>{if(!o||!t||!e)return;const r=function(e,t){const n=document.caretRangeFromPoint?.(e,t);if(!n)return null;const o=n.startContainer,r=1===o.nodeType?o:o.parentElement,i=r?.closest(".ce-block");if(!i)return null;const l=blockForHolder(i);if(!l)return null;const c=[...i.querySelectorAll("[contenteditable]")],s=c.find(e=>e.contains(o));if(!s)return{id:l.id,input:0,offset:0};const a=document.createRange();return a.selectNodeContents(s),a.setEnd(o,n.startOffset),{id:l.id,input:c.indexOf(s),offset:a.toString().length}}(n.clientX,n.clientY);window.getSelection()?.removeAllRanges(),r&&(n.preventDefault(),setReadMode(!1,r))}),window.setThemeColors=function(e){if(!e)return;const t=document.documentElement;for(const n in e)t.style.setProperty(`--${n}`,e[n])},window.loadNote=function(o){if(!e)return;document.getElementById("noteTitleInput").innerText=o.title||"",updateTitlePlaceholder();let r=[];o.plainTextFallback&&o.plainText?r=[{type:"paragraph",data:{text:o.plainText.replace(/\n/g,"
")}}]:o.valueJson&&(r=o.valueJson),l=!1,stopHistory(),u=o.history||null,e.render({blocks:r}).then(()=>{if(n=JSON.stringify(r),resetHistory(),o.viewState?restoreViewState(o.viewState):function(t){if(!e||!t||t.index<0)return;const n=e.blocks.getBlocksCount();if(0===n)return;const o=e.blocks.getBlockByIndex(Math.min(t.index,n-1));o?.holder&&requestAnimationFrame(()=>{const e=o.holder.getBoundingClientRect().top;window.scrollTo(0,window.scrollY+e-(t.offset||0))})}(o.anchor),o.focusStart&&!t&&!o.viewState?.caretId){const t=o.viewState;let n=t?.topId?e.blocks.getById(t.topId):null;if(!n&&t?.topIndex>=0){const o=e.blocks.getBlocksCount();o>0&&(n=e.blocks.getBlockByIndex(Math.min(t.topIndex,o-1)))}try{n?e.caret.setToBlock(n,"start"):e.caret.setToFirstBlock("start")}catch(e){console.error("[Editor] placing the caret failed:",e)}}o.viewState||o.anchor?.index>=0||window.scrollTo(0,0),safeAndroidCall("onNoteRendered"),requestAnimationFrame(()=>requestAnimationFrame(()=>{l=!0,reportViewState()}))})},window.uploadAttachment=async function(e){const t=await requestUpload("file",e);return t&&t.success&&t.file?{success:1,file:{url:t.file.url,name:e.name,size:e.size,extension:e.name.split(".").pop()}}:{success:0,file:null}},window.toggleReadModeFromAndroid=function(){setReadMode(!t,null)},window.setReadMode=setReadMode,window.saveContent=saveContent,window.currentBlockIndex=function(){return e?e.blocks.getCurrentBlockIndex():-1},window.insertUploadedBlockFromAndroid=function(t,n,o){if(!e||!n||!n.url)return;const r=e.blocks.getBlocksCount(),i=o>=0&&o<=r?o:r;"image"===t?e.blocks.insert("image",{file:n},void 0,i,!1):e.blocks.insert("attaches",{file:n,title:n.name||""},void 0,i,!1)},window.flushContent=flushContent,window.finishPage=function(e){return(e?runHistoryAction(()=>(t?Promise.resolve():captureHistory(!0)).then(()=>{if(!a.current)return;const e=a.undo.map(exportedEntry),t=a.redo.map(exportedEntry),size=e=>e.reduce((e,t)=>e+t.json.length,0);let n=a.current.json.length+size(e)+size(t);for(;n>d&&(e.length||t.length);)n-=(e.length>=t.length?e.shift():t.shift()).json.length;n>d||safeAndroidCall("onHistoryExported",JSON.stringify({current:exportedEntry(a.current),undo:e,redo:t}))})):Promise.resolve()).catch(e=>console.error("[History] export failed:",e)).then(()=>flushContent()).finally(()=>safeAndroidCall("onPageFinished"))},window.currentViewStateJson=function(){return e&&l?JSON.stringify(currentViewState()):null},window.focusCaretFromAndroid=function(){if(!e||t)return;const n=document.activeElement;if(n&&n.isContentEditable)return void n.focus({preventScroll:!0});const o=window.scrollY;try{e.caret.setToFirstBlock("start")}catch(e){console.error("[Editor] placing the caret failed:",e)}window.scrollTo(0,o)},window.historyUndo=historyUndo,window.historyRedo=historyRedo,window.historyEditorChanged=function(e){historyNoteChange((Array.isArray(e)?e:[e]).some(e=>e&&e.type&&"block-changed"!==e.type),!0)},window.uploadImage=async function(e){const t=await requestUpload("image",e);return t&&t.success&&t.file?{success:1,file:t.file}:{success:0}}}(); diff --git a/app/src/main/java/androidx/recyclerview/widget/ItemTouchHelperAccess.java b/app/src/main/java/androidx/recyclerview/widget/ItemTouchHelperAccess.java new file mode 100644 index 00000000..76b1cf86 --- /dev/null +++ b/app/src/main/java/androidx/recyclerview/widget/ItemTouchHelperAccess.java @@ -0,0 +1,18 @@ +package androidx.recyclerview.widget; + +import androidx.annotation.NonNull; + +/** + * Reaches the part of {@link ItemTouchHelper} that is not public: after the list was scrolled under + * a dragged card that is held still, it lets the helper check whether the card now covers another + * one, the same check it makes after its own edge scroll. + */ +public final class ItemTouchHelperAccess { + + private ItemTouchHelperAccess() {} + + public static void moveIfNecessary( + @NonNull ItemTouchHelper helper, @NonNull RecyclerView.ViewHolder dragged) { + helper.moveIfNecessary(dragged); + } +} diff --git a/app/src/main/java/com/pasich/mynotes/base/view/MoreNoteMainActivityView.java b/app/src/main/java/com/pasich/mynotes/base/view/MoreNoteMainActivityView.java index c2258a1b..57e4e7a8 100644 --- a/app/src/main/java/com/pasich/mynotes/base/view/MoreNoteMainActivityView.java +++ b/app/src/main/java/com/pasich/mynotes/base/view/MoreNoteMainActivityView.java @@ -9,4 +9,7 @@ public interface MoreNoteMainActivityView { void openCopyNote(long idNote); void callbackDeleteNote(Note mNote); + + /** Opens the note's local version history. */ + void openVersionHistory(int noteId); } diff --git a/app/src/main/java/com/pasich/mynotes/base/view/MoreNoteNoteActivityView.java b/app/src/main/java/com/pasich/mynotes/base/view/MoreNoteNoteActivityView.java index 19fce253..16339877 100644 --- a/app/src/main/java/com/pasich/mynotes/base/view/MoreNoteNoteActivityView.java +++ b/app/src/main/java/com/pasich/mynotes/base/view/MoreNoteNoteActivityView.java @@ -1,5 +1,7 @@ package com.pasich.mynotes.base.view; +import androidx.annotation.Nullable; + public interface MoreNoteNoteActivityView { void changeTextStyle(); @@ -15,4 +17,28 @@ public interface MoreNoteNoteActivityView { void openCopyNote(long idNote); void changeEditor(long idNote); + + /** Saves what is pending and opens the note's local version history. */ + void openVersionHistory(); + + /** Whether the note is being edited, so Undo and Redo apply to it. */ + boolean isEditingNote(); + + /** Whether there is an edit to take back. */ + boolean canUndoEdit(); + + /** Whether there is an undone edit to apply again. */ + boolean canRedoEdit(); + + /** Takes back the last edit. */ + void undoLastEdit(); + + /** Applies the last undone edit again. */ + void redoLastEdit(); + + /** + * Runs {@code observer} whenever editing starts or stops or what Undo and Redo can do changes; + * {@code null} stops it. + */ + void setEditHistoryObserver(@Nullable Runnable observer); } diff --git a/app/src/main/java/com/pasich/mynotes/cache/AppPreferencesCache.java b/app/src/main/java/com/pasich/mynotes/cache/AppPreferencesCache.java index 8ab06bf2..49d69574 100644 --- a/app/src/main/java/com/pasich/mynotes/cache/AppPreferencesCache.java +++ b/app/src/main/java/com/pasich/mynotes/cache/AppPreferencesCache.java @@ -3,6 +3,7 @@ import android.util.Log; import com.pasich.mynotes.data.preferences.SafePreferences; import com.pasich.mynotes.utils.constants.settings.PreferencesConfig; +import com.pasich.mynotes.utils.constants.settings.SortParam; import javax.inject.Inject; import javax.inject.Singleton; @@ -14,6 +15,7 @@ public class AppPreferencesCache { private final SafePreferences prefs; private volatile String lastKnownVersion; private volatile String sortPref; + private volatile boolean customOrder; private volatile String tagsSortPref; private volatile int formatPref; @@ -38,6 +40,9 @@ public synchronized void initialize() { PreferencesConfig.ARGUMENT_PREFERENCE_SORT, PreferencesConfig.ARGUMENT_DEFAULT_SORT_PREF); + customOrder = + prefs.getBoolean(PreferencesConfig.ARGUMENT_PREFERENCE_CUSTOM_ORDER, false); + tagsSortPref = prefs.getString( PreferencesConfig.ARGUMENT_PREFERENCE_TAGS_SORT, @@ -64,6 +69,7 @@ public synchronized void initialize() { private void setDefaults() { lastKnownVersion = "0"; sortPref = PreferencesConfig.ARGUMENT_DEFAULT_SORT_PREF; + customOrder = false; tagsSortPref = PreferencesConfig.ARGUMENT_DEFAULT_TAGS_SORT_PREF; formatPref = PreferencesConfig.ARGUMENT_DEFAULT_FORMAT_VALUE; imageOptEnable = PreferencesConfig.ARGUMENT_DEFAULT_IMAGEOPT_VALUE; @@ -84,15 +90,62 @@ public synchronized void setLastKnownVersion(String version) { } } + /** Whether the one-time "Meet Encly" introduction has been shown. */ + public boolean isMeetEnclyShown() { + return prefs.getBoolean(PreferencesConfig.ARGUMENT_PREFERENCE_MEET_ENCLY_SHOWN, false); + } + + public synchronized void setMeetEnclyShown() { + try { + prefs.putBoolean(PreferencesConfig.ARGUMENT_PREFERENCE_MEET_ENCLY_SHOWN, true); + } catch (Exception e) { + Log.e(TAG, "Failed to save meet-encly flag", e); + } + } + + /** The notes order in effect: {@link SortParam#Custom} or the synced date order. */ public String getSortPref() { ensureInitialized(); - return sortPref; + return customOrder ? SortParam.Custom : getSyncedSortPref(); + } + + /** + * The date order that is backed up and synced, never {@link SortParam#Custom}: older versions + * of the app read anything but "newest first" as "oldest first", and the custom order itself + * only exists on this device. + */ + public String getSyncedSortPref() { + ensureInitialized(); + return SortParam.Custom.equals(sortPref) ? SortParam.DataSort : sortPref; + } + + /** + * Whether the hint about dragging in the custom order may be shown after a long press: never in + * the custom order itself, in selection or in search, and only once ever. + */ + public static boolean customOrderHintDue( + boolean customOrder, boolean selecting, boolean searching, boolean shownBefore) { + return !customOrder && !selecting && !searching && !shownBefore; + } + + public boolean isCustomOrderHintShown() { + return prefs.getBoolean( + PreferencesConfig.ARGUMENT_PREFERENCE_CUSTOM_ORDER_HINT_SHOWN, false); + } + + public void setCustomOrderHintShown() { + prefs.putBoolean(PreferencesConfig.ARGUMENT_PREFERENCE_CUSTOM_ORDER_HINT_SHOWN, true); } public synchronized void setSortPref(String sort) { try { - sortPref = sort; - prefs.putString(PreferencesConfig.ARGUMENT_PREFERENCE_SORT, sort); + boolean custom = SortParam.Custom.equals(sort); + customOrder = custom; + prefs.putBoolean(PreferencesConfig.ARGUMENT_PREFERENCE_CUSTOM_ORDER, custom); + if (!custom) { + sortPref = sort; + prefs.putString(PreferencesConfig.ARGUMENT_PREFERENCE_SORT, sort); + } } catch (Exception e) { Log.e(TAG, "Failed to save sort preference", e); } diff --git a/app/src/main/java/com/pasich/mynotes/cache/NoteOpeningPreferences.java b/app/src/main/java/com/pasich/mynotes/cache/NoteOpeningPreferences.java new file mode 100644 index 00000000..5051f390 --- /dev/null +++ b/app/src/main/java/com/pasich/mynotes/cache/NoteOpeningPreferences.java @@ -0,0 +1,112 @@ +package com.pasich.mynotes.cache; + +import android.content.Context; +import android.content.SharedPreferences; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import dagger.hilt.android.qualifiers.ApplicationContext; +import javax.inject.Inject; +import javax.inject.Singleton; + +/** + * How notes open: in reading or editing mode, at their start or where they were left, and whether a + * double tap in reading mode starts editing. + * + *

Kept in a preferences file of its own on purpose. These settings are not part of {@code + * PreferencesBackup} or Drive sync: an older app version restoring or syncing settings writes back + * only the keys it knows, and would otherwise reset these on every device it touches. + */ +@Singleton +public class NoteOpeningPreferences { + + /** Mode a note opens in. */ + public enum OpenMode { + /** As before this setting: reading in the simple editor, editing in the extended one. */ + AUTO, + READ, + EDIT + } + + /** Where in the note it opens. */ + public enum OpenPosition { + START, + /** Where it was left on this device, see {@code NoteViewStateStore}. */ + LAST + } + + static final String FILE = "note_opening"; + static final String KEY_MODE = "open_mode"; + static final String KEY_POSITION = "open_position"; + static final String KEY_DOUBLE_TAP = "double_tap_to_edit"; + + public static final OpenMode DEFAULT_MODE = OpenMode.AUTO; + public static final OpenPosition DEFAULT_POSITION = OpenPosition.LAST; + public static final boolean DEFAULT_DOUBLE_TAP = false; + + private final SharedPreferences prefs; + + @Inject + public NoteOpeningPreferences(@ApplicationContext @NonNull Context context) { + this.prefs = + context.getApplicationContext().getSharedPreferences(FILE, Context.MODE_PRIVATE); + } + + @NonNull + public OpenMode getOpenMode() { + return parse(prefs.getString(KEY_MODE, null), OpenMode.class, DEFAULT_MODE); + } + + public void setOpenMode(@NonNull OpenMode mode) { + prefs.edit().putString(KEY_MODE, mode.name()).apply(); + } + + @NonNull + public OpenPosition getOpenPosition() { + return parse(prefs.getString(KEY_POSITION, null), OpenPosition.class, DEFAULT_POSITION); + } + + public void setOpenPosition(@NonNull OpenPosition position) { + prefs.edit().putString(KEY_POSITION, position.name()).apply(); + } + + public boolean isDoubleTapToEdit() { + return prefs.getBoolean(KEY_DOUBLE_TAP, DEFAULT_DOUBLE_TAP); + } + + public void setDoubleTapToEdit(boolean enabled) { + prefs.edit().putBoolean(KEY_DOUBLE_TAP, enabled).apply(); + } + + /** Whether the note's last position on this device is restored when it opens. */ + public boolean restoresLastPosition() { + return getOpenPosition() == OpenPosition.LAST; + } + + /** + * Whether a note opens ready for editing. + * + * @param mode the chosen mode. + * @param extendedEditor whether the note opens in the extended editor. + * @param newNote whether the note is being created; a new note always opens for editing. + */ + public static boolean opensInEditMode( + @NonNull OpenMode mode, boolean extendedEditor, boolean newNote) { + if (newNote) return true; + return switch (mode) { + case READ -> false; + case EDIT -> true; + case AUTO -> extendedEditor; + }; + } + + @NonNull + static > E parse(@Nullable String value, Class type, @NonNull E fallback) { + if (value == null) return fallback; + try { + return Enum.valueOf(type, value); + } catch (IllegalArgumentException e) { + // A value from a newer version this one does not know. + return fallback; + } + } +} diff --git a/app/src/main/java/com/pasich/mynotes/data/AppDataManager.java b/app/src/main/java/com/pasich/mynotes/data/AppDataManager.java index e5938bf0..f7853b57 100644 --- a/app/src/main/java/com/pasich/mynotes/data/AppDataManager.java +++ b/app/src/main/java/com/pasich/mynotes/data/AppDataManager.java @@ -3,6 +3,7 @@ import android.content.Context; import android.net.Uri; import com.pasich.mynotes.data.database.DbHelper; +import com.pasich.mynotes.data.database.entities.NoteVersionEntity; import com.pasich.mynotes.data.model.Note; import com.pasich.mynotes.data.model.Tag; import com.pasich.mynotes.data.model.Task; @@ -363,6 +364,21 @@ public Completable setPinNote(int noteId, boolean pinned) { return dbHelper.setPinNote(noteId, pinned); } + @Override + public Completable moveNoteInCustomOrder(int noteId, Integer upperId, Integer lowerId) { + return dbHelper.moveNoteInCustomOrder(noteId, upperId, lowerId); + } + + @Override + public Flowable> getNoteVersions(int noteId) { + return dbHelper.getNoteVersions(noteId); + } + + @Override + public Single restoreNoteVersion(int noteId, long versionId) { + return dbHelper.restoreNoteVersion(noteId, versionId); + } + @Override public Flowable> getActiveTasks() { return dbHelper.getActiveTasks(); diff --git a/app/src/main/java/com/pasich/mynotes/data/database/AppDatabase.java b/app/src/main/java/com/pasich/mynotes/data/database/AppDatabase.java index 3da62cf1..2ba76620 100644 --- a/app/src/main/java/com/pasich/mynotes/data/database/AppDatabase.java +++ b/app/src/main/java/com/pasich/mynotes/data/database/AppDatabase.java @@ -8,6 +8,7 @@ import androidx.room.migration.Migration; import androidx.sqlite.db.SupportSQLiteDatabase; import com.pasich.mynotes.data.database.dao.NoteDao; +import com.pasich.mynotes.data.database.dao.NoteVersionDao; import com.pasich.mynotes.data.database.dao.SyncConflictDao; import com.pasich.mynotes.data.database.dao.SyncMetadataDao; import com.pasich.mynotes.data.database.dao.SyncPendingPreferencesDao; @@ -16,6 +17,7 @@ import com.pasich.mynotes.data.database.dao.TaskCategoryDao; import com.pasich.mynotes.data.database.dao.TaskDao; import com.pasich.mynotes.data.database.dao.Transactions; +import com.pasich.mynotes.data.database.entities.NoteVersionEntity; import com.pasich.mynotes.data.database.entities.SyncConflictEntity; import com.pasich.mynotes.data.database.entities.SyncMetadataEntity; import com.pasich.mynotes.data.database.entities.SyncPendingPreferencesEntity; @@ -40,7 +42,8 @@ SyncMetadataEntity.class, SyncPendingPreferencesEntity.class, SyncConflictEntity.class, - SyncStateEntity.class + SyncStateEntity.class, + NoteVersionEntity.class }, autoMigrations = {@AutoMigration(from = 1, to = 2)}) @Singleton @@ -254,6 +257,46 @@ public void migrate(@NonNull SupportSQLiteDatabase database) { } }; + /** + * Adds the local version history of notes and each note's place in a custom order. + * + *

{@code note_versions} has no foreign key on purpose; see {@link NoteVersionEntity}. The + * custom order starts as the default one, newest edit first, so choosing "Custom" for the first + * time shows the list the user already knows; gaps of {@code 1024} leave room for moves. + */ + public static final Migration MIGRATION_22_23 = + new Migration(22, 23) { + @Override + public void migrate(@NonNull SupportSQLiteDatabase database) { + database.execSQL( + "CREATE TABLE IF NOT EXISTS `note_versions` (" + + "`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, " + + "`noteLocalId` INTEGER NOT NULL, " + + "`noteStableId` TEXT, " + + "`title` TEXT NOT NULL, " + + "`value` TEXT NOT NULL, " + + "`valueJson` TEXT, " + + "`attachments` TEXT, " + + "`createdAt` INTEGER NOT NULL, " + + "`reason` TEXT NOT NULL)"); + database.execSQL( + "CREATE INDEX IF NOT EXISTS `index_note_versions_noteLocalId_createdAt` " + + "ON `note_versions` (`noteLocalId`, `createdAt`)"); + database.execSQL( + "CREATE INDEX IF NOT EXISTS `index_note_versions_noteStableId` " + + "ON `note_versions` (`noteStableId`)"); + database.execSQL( + "ALTER TABLE `notes` " + + "ADD COLUMN `customPosition` INTEGER NOT NULL DEFAULT 0"); + database.execSQL( + "UPDATE `notes` SET `customPosition` = 1024 * (1 + (" + + "SELECT COUNT(*) FROM `notes` AS `other` " + + "WHERE `other`.`date` < `notes`.`date` " + + "OR (`other`.`date` = `notes`.`date` " + + "AND `other`.`id` < `notes`.`id`)))"); + } + }; + private static void insertMetadataForExistingRecords( SupportSQLiteDatabase database, String recordType, @@ -469,4 +512,6 @@ public static void setContext(Context context) { public abstract SyncStateDao syncStateDao(); public abstract SyncPendingPreferencesDao syncPendingPreferencesDao(); + + public abstract NoteVersionDao noteVersionDao(); } diff --git a/app/src/main/java/com/pasich/mynotes/data/database/AppDbHelper.java b/app/src/main/java/com/pasich/mynotes/data/database/AppDbHelper.java index 290fd33b..f13c5c57 100644 --- a/app/src/main/java/com/pasich/mynotes/data/database/AppDbHelper.java +++ b/app/src/main/java/com/pasich/mynotes/data/database/AppDbHelper.java @@ -1,10 +1,12 @@ package com.pasich.mynotes.data.database; +import com.pasich.mynotes.data.database.entities.NoteVersionEntity; import com.pasich.mynotes.data.model.Note; import com.pasich.mynotes.data.model.Tag; import com.pasich.mynotes.data.model.Task; import com.pasich.mynotes.data.model.TaskCategory; import com.pasich.mynotes.data.sync.SyncMutationCoordinator; +import com.pasich.mynotes.utils.editor.NoteViewStateStore; import com.pasich.mynotes.utils.managers.SystemTagsManager; import io.reactivex.Completable; import io.reactivex.Flowable; @@ -20,11 +22,16 @@ public class AppDbHelper implements DbHelper { private final AppDatabase appDatabase; private final SyncMutationCoordinator syncMutationCoordinator; + private final NoteViewStateStore noteViewStateStore; @Inject - AppDbHelper(AppDatabase appDatabase, SyncMutationCoordinator syncMutationCoordinator) { + AppDbHelper( + AppDatabase appDatabase, + SyncMutationCoordinator syncMutationCoordinator, + NoteViewStateStore noteViewStateStore) { this.appDatabase = appDatabase; this.syncMutationCoordinator = syncMutationCoordinator; + this.noteViewStateStore = noteViewStateStore; } @Override @@ -107,7 +114,8 @@ public Completable transferNotesOutTrash(List ids) { @Override public Completable clearTrash() { - return Completable.fromAction(syncMutationCoordinator::deleteAllTrashNotes); + return Completable.fromAction( + () -> noteViewStateStore.removeAll(syncMutationCoordinator.deleteAllTrashNotes())); } @Override @@ -172,12 +180,21 @@ public Completable addNotes(List notes) { @Override public Completable deleteNote(Note note) { - return Completable.fromAction(() -> syncMutationCoordinator.deleteNote(note)); + return Completable.fromAction( + () -> { + syncMutationCoordinator.deleteNote(note); + noteViewStateStore.remove(note.getId()); + }); } @Override public Completable deleteNote(ArrayList notes) { - return Completable.fromAction(() -> syncMutationCoordinator.deleteNotes(notes)); + return Completable.fromAction( + () -> { + syncMutationCoordinator.deleteNotes(notes); + if (notes == null) return; + for (Note note : notes) noteViewStateStore.remove(note.getId()); + }); } @Override @@ -253,6 +270,23 @@ public Completable setPinNote(int noteId, boolean pinned) { .subscribeOn(io.reactivex.schedulers.Schedulers.io()); } + @Override + public Completable moveNoteInCustomOrder(int noteId, Integer upperId, Integer lowerId) { + return Completable.fromAction( + () -> syncMutationCoordinator.moveNoteInCustomOrder(noteId, upperId, lowerId)); + } + + @Override + public Flowable> getNoteVersions(int noteId) { + return appDatabase.noteVersionDao().observeForNote(noteId); + } + + @Override + public Single restoreNoteVersion(int noteId, long versionId) { + return Single.fromCallable( + () -> syncMutationCoordinator.restoreNoteVersion(noteId, versionId)); + } + // ---- DbTasksHelper ---- @Override diff --git a/app/src/main/java/com/pasich/mynotes/data/database/dao/NoteDao.java b/app/src/main/java/com/pasich/mynotes/data/database/dao/NoteDao.java index ff0a6198..3b407738 100644 --- a/app/src/main/java/com/pasich/mynotes/data/database/dao/NoteDao.java +++ b/app/src/main/java/com/pasich/mynotes/data/database/dao/NoteDao.java @@ -26,6 +26,16 @@ public interface NoteDao { @Query("SELECT * FROM notes WHERE isTrash = 1") List getTrashNotesSync(); + /** Every note, trashed ones included — the Encly hand-off sends them all. */ + @Query("SELECT * FROM notes ORDER BY id") + List getAllNotesSync(); + + @Query("SELECT id FROM notes") + List getAllNoteIdsSync(); + + @Query("DELETE FROM notes") + void deleteAllNotes(); + @Query("SELECT * FROM notes WHERE id = :id LIMIT 1") Note getNoteSync(int id); @@ -106,6 +116,14 @@ void updateNoteContent( "SELECT * FROM notes WHERE reminderTime IS NOT NULL AND reminderTime > :now AND isTrash = 0") List getNotesWithActiveRemindersSync(long now); + /** Every note holding a reminder, overdue ones included, outside the trash. */ + @Query("SELECT * FROM notes WHERE reminderTime IS NOT NULL AND isTrash = 0") + List getNotesWithRemindersSync(); + + @Query( + "SELECT COUNT(*) FROM notes WHERE reminderTime IS NOT NULL AND reminderTime > :now AND isTrash = 0") + int countUpcomingRemindersSync(long now); + @Query( "UPDATE notes SET reminderTime = NULL, reminderRepeat = 'NONE', reminderIntervalMinutes = 0 WHERE id = :noteId") void clearReminderSync(int noteId); @@ -119,4 +137,16 @@ void updateNoteContent( @Query("UPDATE notes SET isPinned = :pinned WHERE id = :noteId") void setPinNoteSync(int noteId, boolean pinned); + + /** The highest place in the custom order, or 0 when there are no notes. */ + @Query("SELECT COALESCE(MAX(customPosition), 0) FROM notes") + long getHighestCustomPositionSync(); + + /** A note's place in the custom order, or null when the note is gone. */ + @Query("SELECT customPosition FROM notes WHERE id = :noteId") + Long getCustomPositionSync(int noteId); + + /** Moves a note in the custom order; touches nothing else, so it is not an edit. */ + @Query("UPDATE notes SET customPosition = :position WHERE id = :noteId") + void setCustomPositionSync(int noteId, long position); } diff --git a/app/src/main/java/com/pasich/mynotes/data/database/dao/NoteVersionDao.java b/app/src/main/java/com/pasich/mynotes/data/database/dao/NoteVersionDao.java new file mode 100644 index 00000000..ae70235f --- /dev/null +++ b/app/src/main/java/com/pasich/mynotes/data/database/dao/NoteVersionDao.java @@ -0,0 +1,54 @@ +package com.pasich.mynotes.data.database.dao; + +import androidx.room.Dao; +import androidx.room.Insert; +import androidx.room.Query; +import com.pasich.mynotes.data.database.entities.NoteVersionEntity; +import io.reactivex.Flowable; +import java.util.List; + +/** DAO for the local, never-synced version history of notes. */ +@Dao +public interface NoteVersionDao { + + @Insert + long insert(NoteVersionEntity version); + + /** The most recent version of a note, or null when it has none. */ + @Query( + "SELECT * FROM note_versions WHERE noteLocalId = :noteId " + + "ORDER BY createdAt DESC, id DESC LIMIT 1") + NoteVersionEntity getLatest(int noteId); + + @Query("SELECT * FROM note_versions WHERE id = :id LIMIT 1") + NoteVersionEntity getById(long id); + + /** Every version of a note, newest first. */ + @Query( + "SELECT * FROM note_versions WHERE noteLocalId = :noteId " + + "ORDER BY createdAt DESC, id DESC") + Flowable> observeForNote(int noteId); + + @Query( + "SELECT * FROM note_versions WHERE noteLocalId = :noteId " + + "ORDER BY createdAt DESC, id DESC") + List getForNoteSync(int noteId); + + /** Keeps only the newest {@code keep} versions of a note. */ + @Query( + "DELETE FROM note_versions WHERE noteLocalId = :noteId AND id NOT IN (" + + "SELECT id FROM note_versions WHERE noteLocalId = :noteId " + + "ORDER BY createdAt DESC, id DESC LIMIT :keep)") + void prune(int noteId, int keep); + + /** The caller keeps the list under SQLite's bound-variable limit. */ + @Query("DELETE FROM note_versions WHERE noteLocalId IN (:noteIds)") + void deleteForNotes(List noteIds); + + @Query("DELETE FROM note_versions") + void deleteAll(); + + /** Removes history whose note no longer exists on this device. */ + @Query("DELETE FROM note_versions WHERE noteLocalId NOT IN (SELECT id FROM notes)") + int deleteOrphans(); +} diff --git a/app/src/main/java/com/pasich/mynotes/data/database/dao/TagsDao.java b/app/src/main/java/com/pasich/mynotes/data/database/dao/TagsDao.java index 9f19484f..705e0422 100644 --- a/app/src/main/java/com/pasich/mynotes/data/database/dao/TagsDao.java +++ b/app/src/main/java/com/pasich/mynotes/data/database/dao/TagsDao.java @@ -27,6 +27,15 @@ public interface TagsDao { @Query("SELECT * FROM tags WHERE name = :name LIMIT 1") Tag getTagByNameSync(String name); + @Query( + "SELECT * FROM tags WHERE systemAction = " + + SystemTagsManager.SYSTEM_ACTION_USER_TAG + + " ORDER BY position ASC, id ASC") + List getUserTagsSync(); + + @Query("DELETE FROM tags WHERE systemAction = " + SystemTagsManager.SYSTEM_ACTION_USER_TAG) + void deleteUserTags(); + @Query("DELETE FROM tags WHERE id = :id") void deleteById(long id); diff --git a/app/src/main/java/com/pasich/mynotes/data/database/dao/TaskCategoryDao.java b/app/src/main/java/com/pasich/mynotes/data/database/dao/TaskCategoryDao.java index 3f2eebf6..3b4ab16f 100644 --- a/app/src/main/java/com/pasich/mynotes/data/database/dao/TaskCategoryDao.java +++ b/app/src/main/java/com/pasich/mynotes/data/database/dao/TaskCategoryDao.java @@ -25,6 +25,9 @@ public interface TaskCategoryDao { @Query("SELECT * FROM task_categories ORDER BY position ASC, id ASC") List getCategoriesSync(); + @Query("DELETE FROM task_categories") + void deleteAllCategories(); + @Insert(onConflict = OnConflictStrategy.REPLACE) long insertCategory(TaskCategory category); diff --git a/app/src/main/java/com/pasich/mynotes/data/database/dao/TaskDao.java b/app/src/main/java/com/pasich/mynotes/data/database/dao/TaskDao.java index 30808808..ec9d0b4c 100644 --- a/app/src/main/java/com/pasich/mynotes/data/database/dao/TaskDao.java +++ b/app/src/main/java/com/pasich/mynotes/data/database/dao/TaskDao.java @@ -33,6 +33,15 @@ public interface TaskDao { @Query("SELECT * FROM tasks WHERE isDone = 0 ORDER BY position ASC, createdAt ASC LIMIT 10") List getActiveTasksSync(); + @Query("SELECT * FROM tasks ORDER BY position ASC, createdAt ASC") + List getAllTasksSync(); + + @Query("SELECT id FROM tasks") + List getAllTaskIdsSync(); + + @Query("DELETE FROM tasks") + void deleteAllTasks(); + @Insert(onConflict = OnConflictStrategy.REPLACE) long insertTask(Task task); diff --git a/app/src/main/java/com/pasich/mynotes/data/database/entities/NoteVersionEntity.java b/app/src/main/java/com/pasich/mynotes/data/database/entities/NoteVersionEntity.java new file mode 100644 index 00000000..4b7b6432 --- /dev/null +++ b/app/src/main/java/com/pasich/mynotes/data/database/entities/NoteVersionEntity.java @@ -0,0 +1,60 @@ +package com.pasich.mynotes.data.database.entities; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.room.Entity; +import androidx.room.Index; +import androidx.room.PrimaryKey; + +/** + * One saved earlier state of a note, kept on this device only. + * + *

Deliberately without a foreign key to {@code notes}: notes are written with REPLACE inserts (a + * sync apply, a restore), and SQLite carries out a REPLACE as a delete followed by an insert, so an + * {@code ON DELETE CASCADE} would wipe a note's history every time the note was applied. History is + * removed explicitly when the note itself is deleted for good, plus an orphan sweep. + */ +@Entity( + tableName = "note_versions", + indices = {@Index(value = {"noteLocalId", "createdAt"}), @Index(value = {"noteStableId"})}) +public class NoteVersionEntity { + + @PrimaryKey(autoGenerate = true) + public long id; + + /** The note's row id on this device. */ + public int noteLocalId; + + /** The note's sync identity when it had one, so the version can be traced across id changes. */ + @Nullable public String noteStableId; + + @NonNull public String title; + @NonNull public String value; + @Nullable public String valueJson; + @Nullable public String attachments; + + /** When this state was replaced, which is when it was captured. */ + public long createdAt; + + /** Why it was captured; one of {@link com.pasich.mynotes.data.history.NoteVersionReason}. */ + @NonNull public String reason; + + public NoteVersionEntity( + int noteLocalId, + @Nullable String noteStableId, + @NonNull String title, + @NonNull String value, + @Nullable String valueJson, + @Nullable String attachments, + long createdAt, + @NonNull String reason) { + this.noteLocalId = noteLocalId; + this.noteStableId = noteStableId; + this.title = title; + this.value = value; + this.valueJson = valueJson; + this.attachments = attachments; + this.createdAt = createdAt; + this.reason = reason; + } +} diff --git a/app/src/main/java/com/pasich/mynotes/data/database/helpers/DbNotesHelper.java b/app/src/main/java/com/pasich/mynotes/data/database/helpers/DbNotesHelper.java index f41ec3f8..253bf231 100644 --- a/app/src/main/java/com/pasich/mynotes/data/database/helpers/DbNotesHelper.java +++ b/app/src/main/java/com/pasich/mynotes/data/database/helpers/DbNotesHelper.java @@ -1,5 +1,6 @@ package com.pasich.mynotes.data.database.helpers; +import com.pasich.mynotes.data.database.entities.NoteVersionEntity; import com.pasich.mynotes.data.model.Note; import io.reactivex.Completable; import io.reactivex.Flowable; @@ -60,4 +61,16 @@ Completable updateNoteReminderFull( int noteId, long reminderTime, String repeat, int intervalMinutes); Completable setPinNote(int noteId, boolean pinned); + + /** + * Moves a note in the custom order to between two neighbours (null at either end). Local only: + * not an edit, not synced. + */ + Completable moveNoteInCustomOrder(int noteId, Integer upperId, Integer lowerId); + + /** The note's local version history, newest first; re-emits as it changes. */ + Flowable> getNoteVersions(int noteId); + + /** Restores one version; emits false when the note or the version is gone. */ + Single restoreNoteVersion(int noteId, long versionId); } diff --git a/app/src/main/java/com/pasich/mynotes/data/history/NoteHistory.java b/app/src/main/java/com/pasich/mynotes/data/history/NoteHistory.java new file mode 100644 index 00000000..8ec6c286 --- /dev/null +++ b/app/src/main/java/com/pasich/mynotes/data/history/NoteHistory.java @@ -0,0 +1,182 @@ +package com.pasich.mynotes.data.history; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import com.pasich.mynotes.data.database.dao.NoteVersionDao; +import com.pasich.mynotes.data.database.entities.NoteVersionEntity; +import com.pasich.mynotes.data.model.Note; +import java.util.List; + +/** + * Decides when an earlier state of a note is worth keeping, and keeps the history bounded. + * + *

History is local to this device and never synced. Every method is meant to run inside the + * caller's Room transaction, so a snapshot and the write that replaces its content commit or fail + * together. + * + *

The editor autosaves a few seconds after every pause in typing, so keeping each save would + * fill the history with near-identical states. An autosave keeps the state it replaces at most once + * per {@link #AUTOSAVE_INTERVAL_MILLIS}, or at once when the edit removes a lot of text — the + * select-all-and-type accident this history exists for. A state about to be overwritten by + * something other than the user's typing (a sync, a conflict resolution, a restore) is always kept. + */ +public final class NoteHistory { + + /** Shortest time between two autosave snapshots of one note. */ + public static final long AUTOSAVE_INTERVAL_MILLIS = 10 * 60_000L; + + /** Removing at least this many characters keeps a snapshot regardless of the interval. */ + public static final int LARGE_EDIT_CHARS = 200; + + /** Versions kept per note; the oldest go first. */ + public static final int MAX_VERSIONS_PER_NOTE = 20; + + /** Keeps every {@code IN (...)} clause under SQLite's bound-variable limit. */ + private static final int QUERY_CHUNK = 500; + + /** Looks up a note's sync identity, or returns null when it has none yet. */ + public interface StableIds { + @Nullable + String of(int noteId); + } + + /** History that keeps nothing; for callers built without a database. */ + public static final NoteHistory NONE = new NoteHistory(null, noteId -> null); + + @Nullable private final NoteVersionDao dao; + @NonNull private final StableIds stableIds; + + public NoteHistory(@Nullable NoteVersionDao dao, @NonNull StableIds stableIds) { + this.dao = dao; + this.stableIds = stableIds; + } + + /** + * Keeps {@code stored} before the editor replaces it with {@code incoming}, when the interval + * has passed since the last snapshot or the edit removes a lot of text. + * + * @return true when a version was written. + */ + public boolean recordBeforeEdit(@Nullable Note stored, @NonNull Note incoming, long now) { + if (dao == null || stored == null || sameText(stored, incoming) || !hasText(stored)) { + return false; + } + NoteVersionEntity latest = dao.getLatest(stored.getId()); + boolean due = + latest == null + || now - latest.createdAt >= AUTOSAVE_INTERVAL_MILLIS + // A clock moved backwards must not silence history until it catches up. + || now < latest.createdAt; + if (!due && !isLargeEdit(stored, incoming)) return false; + return write(stored, latest, NoteVersionReason.AUTOSAVE, now); + } + + /** + * Keeps {@code stored} before something other than the user's typing overwrites it. + * + * @param incoming what will replace it, or null when unknown; nothing is kept when it carries + * the same text. + * @return true when a version was written. + */ + public boolean recordBeforeOverwrite( + @Nullable Note stored, + @Nullable Note incoming, + @NonNull NoteVersionReason reason, + long now) { + if (dao == null || stored == null || !hasText(stored)) return false; + if (incoming != null && sameText(stored, incoming)) return false; + return write(stored, dao.getLatest(stored.getId()), reason, now); + } + + /** One version by its id, or null when it is gone. */ + @Nullable + public NoteVersionEntity get(long versionId) { + return dao == null ? null : dao.getById(versionId); + } + + /** Removes the history of notes that are gone for good. */ + public void forget(@Nullable List noteIds) { + if (dao == null || noteIds == null || noteIds.isEmpty()) return; + for (int start = 0; start < noteIds.size(); start += QUERY_CHUNK) { + dao.deleteForNotes( + noteIds.subList(start, Math.min(noteIds.size(), start + QUERY_CHUNK))); + } + } + + public void forgetAll() { + if (dao != null) dao.deleteAll(); + } + + /** Removes history whose note no longer exists; returns how many versions went. */ + public int sweepOrphans() { + return dao == null ? 0 : dao.deleteOrphans(); + } + + private boolean write( + @NonNull Note stored, + @Nullable NoteVersionEntity latest, + @NonNull NoteVersionReason reason, + long now) { + // Already kept: a second copy of the same text would only push an older one out. + if (latest != null && sameText(latest, stored)) return false; + dao.insert( + new NoteVersionEntity( + stored.getId(), + stableIds.of(stored.getId()), + stored.getTitle(), + stored.getValue(), + stored.getValueJson(), + stored.getAttachments(), + now, + reason.name())); + dao.prune(stored.getId(), MAX_VERSIONS_PER_NOTE); + return true; + } + + /** True when both carry the same title and body, which is all a version restores. */ + public static boolean sameText(@NonNull Note first, @NonNull Note second) { + return first.getTitle().equals(second.getTitle()) + && first.getValue().equals(second.getValue()) + && first.getValueJson().equals(second.getValueJson()); + } + + static boolean sameText(@NonNull NoteVersionEntity version, @NonNull Note note) { + return version.title.equals(note.getTitle()) + && version.value.equals(note.getValue()) + && nonNull(version.valueJson).equals(note.getValueJson()); + } + + /** Whether the state has any text worth getting back. */ + static boolean hasText(@NonNull Note note) { + return !note.getTitle().trim().isEmpty() || !note.getValue().trim().isEmpty(); + } + + /** + * Whether going from {@code before} to {@code after} takes out at least {@link + * #LARGE_EDIT_CHARS} characters, or empties a note that had text. + * + *

Measured on the region where the two stop agreeing, so replacing a long passage with + * another of the same length counts, while typing at the end of a long note does not. + */ + static boolean isLargeEdit(@NonNull Note before, @NonNull Note after) { + if (hasText(before) && !hasText(after)) return true; + String first = before.getTitle() + "\n" + before.getValue(); + String second = after.getTitle() + "\n" + after.getValue(); + int shortest = Math.min(first.length(), second.length()); + int prefix = 0; + while (prefix < shortest && first.charAt(prefix) == second.charAt(prefix)) prefix++; + int suffix = 0; + while (suffix < shortest - prefix + && first.charAt(first.length() - 1 - suffix) + == second.charAt(second.length() - 1 - suffix)) { + suffix++; + } + int removed = first.length() - prefix - suffix; + return removed >= LARGE_EDIT_CHARS; + } + + @NonNull + private static String nonNull(@Nullable String value) { + return value == null ? "" : value; + } +} diff --git a/app/src/main/java/com/pasich/mynotes/data/history/NoteVersionReason.java b/app/src/main/java/com/pasich/mynotes/data/history/NoteVersionReason.java new file mode 100644 index 00000000..eedba732 --- /dev/null +++ b/app/src/main/java/com/pasich/mynotes/data/history/NoteVersionReason.java @@ -0,0 +1,27 @@ +package com.pasich.mynotes.data.history; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +/** Why an earlier state of a note was kept. */ +public enum NoteVersionReason { + /** The editor's autosave replaced it, at most once per interval or after a large edit. */ + AUTOSAVE, + /** A version from Google Drive replaced it during a sync. */ + PRE_SYNC, + /** The user restored an older version over it. */ + PRE_RESTORE, + /** The user settled a sync conflict with the other version. */ + PRE_CONFLICT; + + /** Reads a stored value, treating anything unknown as an ordinary autosave. */ + @NonNull + public static NoteVersionReason fromStored(@Nullable String stored) { + if (stored != null) { + for (NoteVersionReason reason : values()) { + if (reason.name().equals(stored)) return reason; + } + } + return AUTOSAVE; + } +} diff --git a/app/src/main/java/com/pasich/mynotes/data/history/NoteVersionRestore.java b/app/src/main/java/com/pasich/mynotes/data/history/NoteVersionRestore.java new file mode 100644 index 00000000..01f84d0d --- /dev/null +++ b/app/src/main/java/com/pasich/mynotes/data/history/NoteVersionRestore.java @@ -0,0 +1,117 @@ +package com.pasich.mynotes.data.history; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonParser; +import com.pasich.mynotes.data.database.entities.NoteVersionEntity; +import com.pasich.mynotes.data.model.Note; +import com.pasich.mynotes.extendedEditor.attach.EditorAttachmentBlocks; +import java.util.HashSet; +import java.util.Set; + +/** + * The content a note gets when one of its versions is restored. + * + *

History keeps text, not files: an attachment the current note no longer has may already have + * been deleted from the device, and a block pointing at it would render broken. So the version's + * text comes back, and of its attachments only those the note still holds; {@link + * #droppedAttachments} counts the rest so the screen can say so before the user confirms. + * + *

Free of {@code android.*}, so it is testable under ordinary JVM tests. + */ +public final class NoteVersionRestore { + + @NonNull public final String title; + @NonNull public final String value; + @NonNull public final String valueJson; + @Nullable public final String attachments; + public final int droppedAttachments; + + private NoteVersionRestore( + @NonNull String title, + @NonNull String value, + @NonNull String valueJson, + @Nullable String attachments, + int droppedAttachments) { + this.title = title; + this.value = value; + this.valueJson = valueJson; + this.attachments = attachments; + this.droppedAttachments = droppedAttachments; + } + + @NonNull + public static NoteVersionRestore plan( + @NonNull Note current, @NonNull NoteVersionEntity version) { + Set available = + new HashSet<>(EditorAttachmentBlocks.fileUrls(current.getValueJson())); + available.addAll(attachmentUrls(current.getAttachments())); + + String versionJson = version.valueJson == null ? "" : version.valueJson; + EditorAttachmentBlocks.Filtered filtered = + EditorAttachmentBlocks.keepOnlyFiles(versionJson, available); + String valueJson = filtered.valueJson == null ? "" : filtered.valueJson; + + // The attachment column lists the files the document references; only the ones still on + // the device can be listed, which are exactly the current note's that the version uses. + Set referenced = new HashSet<>(EditorAttachmentBlocks.fileUrls(valueJson)); + return new NoteVersionRestore( + version.title, + version.value, + valueJson, + keepOnly(current.getAttachments(), referenced), + filtered.removed); + } + + /** Applies the plan to {@code note}, leaving its tag, reminder and pin as they are. */ + public void applyTo(@NonNull Note note) { + note.setTitle(title); + note.setValue(value); + note.setValueJson(valueJson); + note.setAttachments(attachments); + } + + @NonNull + private static Set attachmentUrls(@Nullable String column) { + Set urls = new HashSet<>(); + JsonArray array = parse(column); + if (array == null) return urls; + for (JsonElement element : array) { + String url = urlOf(element); + if (url != null) urls.add(url); + } + return urls; + } + + @Nullable + private static String keepOnly(@Nullable String column, @NonNull Set urls) { + JsonArray array = parse(column); + if (array == null) return column; + JsonArray kept = new JsonArray(); + for (JsonElement element : array) { + String url = urlOf(element); + if (url != null && urls.contains(url)) kept.add(element); + } + return kept.size() == array.size() ? column : kept.toString(); + } + + @Nullable + private static JsonArray parse(@Nullable String column) { + if (column == null || column.trim().isEmpty()) return null; + try { + JsonElement root = JsonParser.parseString(column); + return root.isJsonArray() ? root.getAsJsonArray() : null; + } catch (RuntimeException unreadable) { + return null; + } + } + + @Nullable + private static String urlOf(@NonNull JsonElement element) { + if (!element.isJsonObject()) return null; + JsonElement url = element.getAsJsonObject().get("url"); + return url != null && url.isJsonPrimitive() ? url.getAsString() : null; + } +} diff --git a/app/src/main/java/com/pasich/mynotes/data/model/Note.java b/app/src/main/java/com/pasich/mynotes/data/model/Note.java index a1069e20..2926bfba 100644 --- a/app/src/main/java/com/pasich/mynotes/data/model/Note.java +++ b/app/src/main/java/com/pasich/mynotes/data/model/Note.java @@ -58,6 +58,15 @@ public class Note { @androidx.room.ColumnInfo(name = "reminderIntervalMinutes") private int reminderIntervalMinutes = 0; + /** + * Place in the user's own order ("Custom" sort): larger comes first, and gaps are left between + * neighbours so a move rewrites one row. Local to this device — kept in local backups, never + * synced (see {@code SyncMetadata.stripDeviceLocalFields}) — and changing it is not an edit. + */ + @SerializedName("n") + @androidx.room.ColumnInfo(name = "customPosition", defaultValue = "0") + private long customPosition = 0L; + /** Initializes note fields and returns this instance. */ public Note create(String title, String value, long date, String tag) { this.title = title; @@ -211,6 +220,14 @@ public void setReminderIntervalMinutes(int reminderIntervalMinutes) { this.reminderIntervalMinutes = reminderIntervalMinutes; } + public long getCustomPosition() { + return customPosition; + } + + public void setCustomPosition(long customPosition) { + this.customPosition = customPosition; + } + /** Copies all mutable fields from another note into this one. */ public void copyFrom(Note other) { if (other == null) return; @@ -224,6 +241,7 @@ public void copyFrom(Note other) { this.reminderRepeat = other.reminderRepeat; this.isPinned = other.isPinned; this.reminderIntervalMinutes = other.reminderIntervalMinutes; + this.customPosition = other.customPosition; } /** Creates a new note copy without id, pinning, or reminder. */ diff --git a/app/src/main/java/com/pasich/mynotes/data/model/ReminderRepeat.java b/app/src/main/java/com/pasich/mynotes/data/model/ReminderRepeat.java index 8f3ca28f..5c54b476 100644 --- a/app/src/main/java/com/pasich/mynotes/data/model/ReminderRepeat.java +++ b/app/src/main/java/com/pasich/mynotes/data/model/ReminderRepeat.java @@ -1,13 +1,19 @@ package com.pasich.mynotes.data.model; -/** Repeat interval options for note reminders. */ +/** + * The fixed repeat options older versions of the app stored, and still the stored form of a daily, + * weekly or monthly reminder. {@link RepeatRule} reads these and everything newer. + */ public enum ReminderRepeat { NONE, DAILY, WEEKLY, MONTHLY; - /** Parses a string to a ReminderRepeat value, defaulting to NONE. */ + /** + * Parses a string to a ReminderRepeat value, defaulting to NONE. A custom rule such as {@code + * EVERY:3:HOURS@…} is NONE here, which is how a version that predates it treats it. + */ public static ReminderRepeat from(String value) { if (value == null) return NONE; try { @@ -16,4 +22,9 @@ public static ReminderRepeat from(String value) { return NONE; } } + + /** The same repeat as a {@link RepeatRule}, anchored on the reminder's own time. */ + public RepeatRule toRule() { + return RepeatRule.parse(name()); + } } diff --git a/app/src/main/java/com/pasich/mynotes/data/model/RepeatRule.java b/app/src/main/java/com/pasich/mynotes/data/model/RepeatRule.java new file mode 100644 index 00000000..dfae60de --- /dev/null +++ b/app/src/main/java/com/pasich/mynotes/data/model/RepeatRule.java @@ -0,0 +1,243 @@ +package com.pasich.mynotes.data.model; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import java.time.Instant; +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.util.Locale; +import java.util.Objects; + +/** + * How a note reminder repeats: every {@code interval} {@link Unit units}, counted from an anchor. + * + *

The rule lives in the existing {@code reminderRepeat} text column and sync field, so it needs + * no migration and older versions of the app keep reading what they understand: + * + *

    + *
  • {@code NONE}, {@code DAILY}, {@code WEEKLY} and {@code MONTHLY} are the legacy values. They + * carry no anchor; the reminder's own time is the anchor. + *
  • {@code EVERY::@} is everything else, for example {@code + * EVERY:3:HOURS@1767225600000}. An app that predates it reads it as a one-time reminder. + *
+ * + *

Every occurrence is computed from the anchor rather than from the previous occurrence, so the + * schedule never drifts and a month or year that is too short does not move later occurrences: a + * monthly reminder anchored on 31 January rings on 28 or 29 February and again on 31 March, and a + * yearly one anchored on 29 February rings on 28 February in other years. Hours are real elapsed + * time; days and longer keep the same wall-clock time across daylight-saving changes. + */ +public final class RepeatRule { + + /** The smallest and largest repeat count the picker accepts. */ + public static final int MIN_INTERVAL = 1; + + public static final int MAX_INTERVAL = 999; + + /** A reminder that does not repeat. */ + public static final RepeatRule NONE = new RepeatRule(0, Unit.DAYS, null); + + private static final String PREFIX = "EVERY:"; + private static final long HOUR_MS = 3_600_000L; + + /** Repeat units, in picker order. */ + public enum Unit { + HOURS(HOUR_MS), + DAYS(25 * HOUR_MS), + WEEKS(7 * 25 * HOUR_MS), + MONTHS(31 * 25 * HOUR_MS), + YEARS(366 * 25 * HOUR_MS); + + /** + * An upper bound on the length of one unit, so that dividing elapsed time by it never + * overshoots the number of whole periods that have passed (25-hour days cover a DST + * change). + */ + final long longestMillis; + + Unit(long longestMillis) { + this.longestMillis = longestMillis; + } + } + + private final int interval; + @NonNull private final Unit unit; + @Nullable private final Long anchor; + + private RepeatRule(int interval, @NonNull Unit unit, @Nullable Long anchor) { + this.interval = interval; + this.unit = unit; + this.anchor = anchor; + } + + /** + * A repeating rule. The interval is clamped to {@link #MIN_INTERVAL}..{@link #MAX_INTERVAL}. + * + * @param anchor the first occurrence, or null to use the reminder's own time + */ + @NonNull + public static RepeatRule every(int interval, @NonNull Unit unit, @Nullable Long anchor) { + int clamped = Math.max(MIN_INTERVAL, Math.min(MAX_INTERVAL, interval)); + return new RepeatRule(clamped, unit, anchor); + } + + /** Reads a stored value. Anything unrecognised, including null, is {@link #NONE}. */ + @NonNull + public static RepeatRule parse(@Nullable String value) { + if (value == null) return NONE; + switch (value) { + case "DAILY": + return new RepeatRule(1, Unit.DAYS, null); + case "WEEKLY": + return new RepeatRule(1, Unit.WEEKS, null); + case "MONTHLY": + return new RepeatRule(1, Unit.MONTHS, null); + default: + break; + } + if (!value.startsWith(PREFIX)) return NONE; + String body = value.substring(PREFIX.length()); + Long anchor = null; + int at = body.indexOf('@'); + if (at >= 0) { + try { + anchor = Long.parseLong(body.substring(at + 1)); + } catch (NumberFormatException e) { + return NONE; + } + body = body.substring(0, at); + } + int colon = body.indexOf(':'); + if (colon <= 0) return NONE; + int interval; + Unit unit; + try { + interval = Integer.parseInt(body.substring(0, colon)); + unit = Unit.valueOf(body.substring(colon + 1)); + } catch (IllegalArgumentException e) { + return NONE; + } + if (interval < MIN_INTERVAL || interval > MAX_INTERVAL) return NONE; + return new RepeatRule(interval, unit, anchor); + } + + /** + * The stored form. A rule an older app version can express without an anchor is written as its + * legacy value, so a reminder set to repeat daily, weekly or monthly keeps working there. + * Monthly needs the anchor only when it falls after the 28th; without it a reminder that landed + * on a short month's last day would stay on that day for good. + */ + @NonNull + public String serialize() { + return serialize(ZoneId.systemDefault()); + } + + @NonNull + String serialize(@NonNull ZoneId zone) { + if (!isRepeating()) return "NONE"; + if (interval == 1) { + if (unit == Unit.DAYS) return "DAILY"; + if (unit == Unit.WEEKS) return "WEEKLY"; + if (unit == Unit.MONTHS && (anchor == null || dayOfMonth(anchor, zone) <= 28)) + return "MONTHLY"; + } + String rule = PREFIX + interval + ":" + unit.name(); + return anchor == null ? rule : rule + "@" + anchor; + } + + public boolean isRepeating() { + return interval > 0; + } + + public int getInterval() { + return interval; + } + + @NonNull + public Unit getUnit() { + return unit; + } + + @Nullable + public Long getAnchor() { + return anchor; + } + + /** This rule counted from {@code newAnchor}. */ + @NonNull + public RepeatRule withAnchor(long newAnchor) { + if (!isRepeating()) return this; + return new RepeatRule(interval, unit, newAnchor); + } + + /** The anchor occurrences are counted from: the stored one, else {@code reminderTime}. */ + public long anchorOr(long reminderTime) { + return anchor != null ? anchor : reminderTime; + } + + /** The first occurrence strictly after {@code after}, in the device's time zone. */ + public long next(long anchor, long after) { + return next(anchor, after, ZoneId.systemDefault()); + } + + /** + * The first occurrence strictly after {@code after}, counting {@code anchor} as occurrence + * zero. However many periods were missed, this is one step: catching up after the phone was off + * for a month gives the next future occurrence, not a burst of the missed ones. + * + * @return {@code anchor} itself when it is still ahead, or -1 when the rule does not repeat + */ + public long next(long anchor, long after, @NonNull ZoneId zone) { + if (!isRepeating()) return -1L; + if (anchor > after) return anchor; + long periodLongest = unit.longestMillis * interval; + // Never more than the true number of elapsed periods, so the loop only walks forward. + long k = Math.max(0L, (after - anchor) / periodLongest); + long candidate = occurrence(anchor, k, zone); + while (candidate <= after) { + k++; + candidate = occurrence(anchor, k, zone); + } + return candidate; + } + + /** Occurrence number {@code k}, counted from the anchor rather than from occurrence k-1. */ + long occurrence(long anchor, long k, @NonNull ZoneId zone) { + long steps = k * interval; + if (unit == Unit.HOURS) return anchor + steps * HOUR_MS; + ZonedDateTime start = Instant.ofEpochMilli(anchor).atZone(zone); + ZonedDateTime at = + switch (unit) { + case DAYS -> start.plusDays(steps); + case WEEKS -> start.plusWeeks(steps); + case MONTHS -> start.plusMonths(steps); + default -> start.plusYears(steps); + }; + return at.toInstant().toEpochMilli(); + } + + private static int dayOfMonth(long epochMillis, @NonNull ZoneId zone) { + return Instant.ofEpochMilli(epochMillis).atZone(zone).getDayOfMonth(); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof RepeatRule)) return false; + RepeatRule other = (RepeatRule) o; + return interval == other.interval + && unit == other.unit + && Objects.equals(anchor, other.anchor); + } + + @Override + public int hashCode() { + return Objects.hash(interval, unit, anchor); + } + + @NonNull + @Override + public String toString() { + return String.format(Locale.ROOT, "RepeatRule(%s)", serialize(ZoneId.of("UTC"))); + } +} diff --git a/app/src/main/java/com/pasich/mynotes/data/order/CustomOrder.java b/app/src/main/java/com/pasich/mynotes/data/order/CustomOrder.java new file mode 100644 index 00000000..a5c2237a --- /dev/null +++ b/app/src/main/java/com/pasich/mynotes/data/order/CustomOrder.java @@ -0,0 +1,67 @@ +package com.pasich.mynotes.data.order; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import com.pasich.mynotes.data.model.Note; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; + +/** + * The rules of the user's own note order ("Custom" sort). + * + *

Each note carries a {@link Note#getCustomPosition() position}; a larger one comes first, and + * pinned notes come before all others. Positions are spread {@link #STEP} apart, so moving a note + * between two others gives it the midpoint and writes one row. Only when two neighbours have run + * out of room between them is the whole order renumbered. + * + *

Free of {@code android.*}, so it is testable under ordinary JVM tests. + */ +public final class CustomOrder { + + /** The gap left between neighbours when positions are handed out. */ + public static final long STEP = 1024L; + + /** Pinned first, then the larger position, then the newer row for equal positions. */ + public static final Comparator COMPARATOR = + (a, b) -> { + if (a.isPinned() != b.isPinned()) return a.isPinned() ? -1 : 1; + int byPosition = Long.compare(b.getCustomPosition(), a.getCustomPosition()); + return byPosition != 0 ? byPosition : Integer.compare(b.getId(), a.getId()); + }; + + private CustomOrder() {} + + /** The position for a note placed at the top of everything at {@code highest}. */ + public static long above(long highest) { + return highest + STEP; + } + + /** + * The position for a note dropped between two neighbours. + * + * @param upper position of the note that will be just above it, or null at the top. + * @param lower position of the note that will be just below it, or null at the bottom. + * @return the new position, or null when there is no room between the two and the order has to + * be renumbered first. + */ + @Nullable + public static Long between(@Nullable Long upper, @Nullable Long lower) { + if (upper == null && lower == null) return STEP; + if (upper == null) return lower + STEP; + if (lower == null) return upper - STEP; + if (upper - lower < 2) return null; + return lower + (upper - lower) / 2; + } + + /** + * Positions for every note in {@code ordered} (top first), {@link #STEP} apart, keeping their + * order. + */ + @NonNull + public static List renumbered(int count) { + List positions = new ArrayList<>(count); + for (int i = 0; i < count; i++) positions.add((long) (count - i) * STEP); + return positions; + } +} diff --git a/app/src/main/java/com/pasich/mynotes/data/preferences/AppPreferencesHelper.java b/app/src/main/java/com/pasich/mynotes/data/preferences/AppPreferencesHelper.java index 93c839ba..fd717d70 100644 --- a/app/src/main/java/com/pasich/mynotes/data/preferences/AppPreferencesHelper.java +++ b/app/src/main/java/com/pasich/mynotes/data/preferences/AppPreferencesHelper.java @@ -78,7 +78,8 @@ public PreferencesBackup getListPreferences() { // OLD FIELDS getFormatCount(), getTypeFaceNoteActivity(), - getSortParam(), + // The date order only: the custom order stays on this device. + appCache.getSyncedSortPref(), getSizeTextNoteActivity(), prefs.getInt( PreferencesConfig.ARGUMENT_PREFERENCE_THEME, 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 9954bdc2..65868472 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 @@ -15,16 +15,21 @@ import com.pasich.mynotes.data.database.entities.SyncMetadataEntity; import com.pasich.mynotes.data.database.entities.SyncPendingPreferencesEntity; import com.pasich.mynotes.data.database.entities.SyncStateEntity; +import com.pasich.mynotes.data.history.NoteHistory; +import com.pasich.mynotes.data.history.NoteVersionReason; import com.pasich.mynotes.data.model.Note; import com.pasich.mynotes.data.model.Tag; import com.pasich.mynotes.data.model.Task; import com.pasich.mynotes.data.model.TaskCategory; +import com.pasich.mynotes.data.order.CustomOrder; import com.pasich.mynotes.data.preferences.PreferenceHelper; import com.pasich.mynotes.extendedEditor.attach.AttachmentStorage; import com.pasich.mynotes.extendedEditor.attach.AttachmentUrl; import com.pasich.mynotes.extendedEditor.attach.EditorAttachmentBlocks; import com.pasich.mynotes.extendedEditor.models.EditorAttachment; import com.pasich.mynotes.utils.backup.models.PreferencesBackup; +import com.pasich.mynotes.utils.editor.NoteViewStateStore; +import com.pasich.mynotes.utils.reminder.ReminderRescheduler; import java.io.File; import java.io.FileInputStream; import java.io.FileOutputStream; @@ -40,6 +45,7 @@ import java.util.LinkedHashSet; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.Set; import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; @@ -65,6 +71,13 @@ public final class RoomSyncStore implements SyncStore { private final TransactionFailureInjector transactionFailureInjector; private final AttachmentHashCache hashCache; + /** + * The local version history of notes. A version from another device, or the side of a conflict + * the user picks, replaces a note's text without the user typing anything here, so the text it + * replaces is always kept first. + */ + private final NoteHistory noteHistory; + /** * Content hash to the note-folder file holding it, indexed while the snapshot is built so the * upload path can find blobs this device owns without duplicating them into the sync cache. @@ -100,6 +113,14 @@ public final class RoomSyncStore implements SyncStore { private final java.util.concurrent.atomic.AtomicBoolean appliedPreferencesChange = new java.util.concurrent.atomic.AtomicBoolean(false); + /** + * Notes whose row this apply wrote or deleted. A reminder set, moved or turned off on another + * device reaches this one only as a row; once the transaction has committed, their alarms are + * armed or cancelled to match, so the change does not wait for the next app start. + */ + private final Set notesToReconcile = + java.util.Collections.synchronizedSet(new HashSet<>()); + public RoomSyncStore( @NonNull Context context, @NonNull AppDatabase database, @@ -147,6 +168,15 @@ public RoomSyncStore( this.transactionFailureInjector = transactionFailureInjector; this.preferences = context.getApplicationContext().getSharedPreferences(PREFS, Context.MODE_PRIVATE); + this.noteHistory = + new NoteHistory( + database.noteVersionDao(), + noteId -> { + SyncMetadataEntity metadata = + database.syncMetadataDao() + .get(SyncMetadata.RECORD_TYPE_NOTE, noteId); + return metadata == null ? null : metadata.stableId; + }); this.hashCache = new AttachmentHashCache( new File( @@ -273,6 +303,7 @@ private void applySnapshotInternal( // The live digest at the moment the journal is written, decided inside the transaction // below; the commit afterwards refuses to run if the settings moved again since. String[] preferencesBaseline = {null}; + notesToReconcile.clear(); try { database.runInTransaction( () -> { @@ -363,36 +394,27 @@ private void applySnapshotInternal( } if (record.isTombstone()) { markDeleted(metadata); - database.syncMetadataDao() - .setVersion( - metadata.recordType, - metadata.localId, - record.getUpdatedAt().toEpochMilli(), - record.getDeletedAt().toEpochMilli()); - database.syncMetadataDao() - .setSyncedVersion( - metadata.recordType, - metadata.localId, - record.getCanonicalPayloadHash()); + recordAppliedVersion( + metadata, + record.getUpdatedAt().toEpochMilli(), + record.getDeletedAt().toEpochMilli(), + record.getCanonicalPayloadHash()); retireConflictsSupersededBy(record); transactionFailureInjector.afterRecordApplied(record); continue; } - if (!applyPayload(metadata, record.getPayload())) { + if (!applyPayload( + metadata, + record.getPayload(), + NoteVersionReason.PRE_SYNC)) { skippedKeys.add(key); continue; } - database.syncMetadataDao() - .setVersion( - metadata.recordType, - metadata.localId, - record.getUpdatedAt().toEpochMilli(), - null); - database.syncMetadataDao() - .setSyncedVersion( - metadata.recordType, - metadata.localId, - record.getCanonicalPayloadHash()); + recordAppliedVersion( + metadata, + record.getUpdatedAt().toEpochMilli(), + null, + record.getCanonicalPayloadHash()); retireConflictsSupersededBy(record); transactionFailureInjector.afterRecordApplied(record); } @@ -444,6 +466,9 @@ private void applySnapshotInternal( } } persistConflicts(conflicts, skippedKeys); + // A note deleted by another device takes its history with it above; + // this also catches history any older path left behind. + noteHistory.sweepOrphans(); if (finalState != null && !deferFinalState) { database.syncStateDao().upsert(toEntity(finalState)); } @@ -452,8 +477,13 @@ private void applySnapshotInternal( } }); } catch (SyncRuntimeException error) { + notesToReconcile.clear(); throw error.ioException; + } catch (RuntimeException error) { + notesToReconcile.clear(); + throw error; } + reconcileReminders(); if (deferFinalState || preferencesBaseline[0] != null) { boolean committed = false; if (preferencesBaseline[0] != null) { @@ -843,12 +873,67 @@ else if (SyncMetadata.RECORD_TYPE_PREFERENCES.equals(metadata.recordType)) { } /** - * Writes one live version over the local row. + * Records the version this device now holds, writing only the columns that differ. + * + *

Every sync applies the whole merged snapshot, and nearly all of it is what this device + * already holds. Rewriting identical bookkeeping for each record made every sync a burst of + * writes for nothing. The row is read afresh rather than taken from the map built before the + * loop, because an earlier record in the same apply (a tag reconciled by name) can have changed + * it since. + */ + private void recordAppliedVersion( + @NonNull SyncMetadataEntity metadata, + long updatedAt, + @Nullable Long deletedAt, + @NonNull String versionId) { + SyncMetadataEntity current = + database.syncMetadataDao().get(metadata.recordType, metadata.localId); + if (current == null + || current.updatedAt != updatedAt + || !Objects.equals(current.deletedAt, deletedAt)) { + database.syncMetadataDao() + .setVersion(metadata.recordType, metadata.localId, updatedAt, deletedAt); + } + if (current == null || !versionId.equals(current.syncedVersionId)) { + database.syncMetadataDao() + .setSyncedVersion(metadata.recordType, metadata.localId, versionId); + } + } + + /** + * Whether two rows of the same entity hold the same values. + * + *

Compared through the same Gson the payloads are read with, which sees every stored column + * (the entities' only non-column fields are UI state that is never set on either side here). + */ + private boolean sameRow(@NonNull Object stored, @NonNull Object incoming) { + return gson.toJsonTree(stored).equals(gson.toJsonTree(incoming)); + } + + private static boolean isEmptyAttachmentList(@Nullable String attachments) { + if (attachments == null) return true; + String trimmed = attachments.trim(); + return trimmed.isEmpty() || trimmed.equals("[]"); + } + + /** + * Writes one live version over the local row, unless the row already holds exactly it. + * + *

The comparison is made against the row this version would produce, not against the version + * hash: a note whose attachment references are rewritten to this device's files on apply hashes + * the same before and after, yet only the apply puts its blocks right. When the row already + * matches, nothing is written, so a sync that changes nothing does not invalidate the notes, + * tags or tasks queries and the lists on screen are not re-emitted. + * + *

A note's text that the version replaces is first kept in the note's local history under + * {@code reason}, and the note keeps its place in this device's custom order, which never + * travels with the version. * * @return false when the version was deliberately not applied and the record's local version * must therefore not be advanced to it. */ - private boolean applyPayload(SyncMetadataEntity metadata, JsonObject payload) + private boolean applyPayload( + SyncMetadataEntity metadata, JsonObject payload, @NonNull NoteVersionReason reason) throws IOException { // A record that was deleted here and then edited on another device has no row left to // update; @Update on a missing row is a silent no-op, and the tombstone was cleared @@ -859,7 +944,25 @@ private boolean applyPayload(SyncMetadataEntity metadata, JsonObject payload) Note note = gson.fromJson(payload, Note.class); note.setId((int) metadata.localId); restoreAttachments(note, payload); - database.noteDao().addNote(note); + Note stored = revive ? null : database.noteDao().getNoteSync(note.getId()); + if (stored != null + && isEmptyAttachmentList(note.getAttachments()) + && isEmptyAttachmentList(stored.getAttachments())) { + // No attachments either way; the editor stores that as "[]" and a received + // version as null. Spelling it the local way keeps the row equal below. + note.setAttachments(stored.getAttachments()); + } + note.setCustomPosition( + stored != null + ? stored.getCustomPosition() + // Brought back after a deletion here: it returns at the top, like a + // new note, since its old place is gone. + : CustomOrder.above(database.noteDao().getHighestCustomPositionSync())); + if (stored == null || !sameRow(stored, note)) { + noteHistory.recordBeforeOverwrite(stored, note, reason, System.currentTimeMillis()); + database.noteDao().addNote(note); + notesToReconcile.add(note.getId()); + } } else if ("task".equals(metadata.recordType)) { Task task = gson.fromJson(payload, Task.class); task.setId((int) metadata.localId); @@ -871,26 +974,32 @@ private boolean applyPayload(SyncMetadataEntity metadata, JsonObject payload) payload.get("categoryStableId").getAsString()); if (category != null) task.setCategoryId((int) category.localId); } - if (revive || database.taskDao().getTaskSync(task.getId()) == null) { + Task stored = revive ? null : database.taskDao().getTaskSync(task.getId()); + if (stored == null) { database.taskDao().insertTask(task); - } else { + } else if (!sameRow(stored, task)) { database.taskDao().updateTask(task); } } else if (SyncMetadata.RECORD_TYPE_CATEGORY.equals(metadata.recordType)) { TaskCategory category = gson.fromJson(payload, TaskCategory.class); category.setId((int) metadata.localId); - if (revive || database.taskCategoryDao().getCategorySync(category.getId()) == null) { + TaskCategory stored = + revive ? null : database.taskCategoryDao().getCategorySync(category.getId()); + if (stored == null) { database.taskCategoryDao().insertCategory(category); - } else { + } else if (!sameRow(stored, category)) { database.taskCategoryDao().updateCategory(category); } } else if ("tag".equals(metadata.recordType)) { Tag tag = gson.fromJson(payload, Tag.class); tag.id = metadata.localId; - if (revive || database.tagsDao().getTagSync(tag.id) == null) { + Tag stored = revive ? null : database.tagsDao().getTagSync(tag.id); + if (stored == null) { return reviveTag(metadata, tag); } - database.tagsDao().updateTag(tag); + if (!sameRow(stored, tag)) { + database.tagsDao().updateTag(tag); + } } else if (SyncMetadata.RECORD_TYPE_PREFERENCES.equals(metadata.recordType)) { // SharedPreferences is outside Room. applySnapshotInternal journals and commits this // payload only after the Room transaction succeeds. @@ -942,6 +1051,9 @@ private long insertRemoteRecord(SyncRecord record) throws IOException { Note note = gson.fromJson(record.getPayload(), Note.class); note.setId(0); note.setAttachments(null); + // The order is this device's own; a note from another device starts at the top. + note.setCustomPosition( + CustomOrder.above(database.noteDao().getHighestCustomPositionSync())); long localId = database.noteDao().addNote(note); note.setId((int) localId); restoreAttachments(note, record.getPayload()); @@ -954,6 +1066,7 @@ private long insertRemoteRecord(SyncRecord record) throws IOException { note.getDate(), note.getTag(), note.getAttachments()); + if (note.getReminderTime() != null) notesToReconcile.add(note.getId()); return localId; } if (record.getType() == SyncRecord.Type.TASK) { @@ -1020,9 +1133,14 @@ private long insertRemoteTag(@NonNull SyncRecord record) { } private void markDeleted(SyncMetadataEntity metadata) { - if ("note".equals(metadata.recordType)) + if ("note".equals(metadata.recordType)) { database.noteDao().deleteById((int) metadata.localId); - else if ("task".equals(metadata.recordType)) + // Deleted for good on another device, or by the side of a conflict picked here. + noteHistory.forget(java.util.Collections.singletonList((int) metadata.localId)); + // A position on this device means nothing once another device deleted the note. + new NoteViewStateStore(context).remove(metadata.localId); + notesToReconcile.add((int) metadata.localId); + } else if ("task".equals(metadata.recordType)) database.taskDao().deleteById((int) metadata.localId); else if (SyncMetadata.RECORD_TYPE_CATEGORY.equals(metadata.recordType)) database.taskCategoryDao().deleteById((int) metadata.localId); @@ -1124,6 +1242,7 @@ public void resolveConflict(long conflictId, @NonNull SyncResolution resolution) return; } + notesToReconcile.clear(); try { database.runInTransaction( () -> { @@ -1141,7 +1260,29 @@ public void resolveConflict(long conflictId, @NonNull SyncResolution resolution) .markResolved(conflictId, resolution.name(), resolvedAt); }); } catch (SyncRuntimeException error) { + notesToReconcile.clear(); throw error.ioException; + } catch (RuntimeException error) { + notesToReconcile.clear(); + throw error; + } + reconcileReminders(); + } + + /** Arms or cancels the alarms of the notes the committed apply changed. */ + private void reconcileReminders() { + List ids; + synchronized (notesToReconcile) { + ids = new ArrayList<>(notesToReconcile); + notesToReconcile.clear(); + } + if (ids.isEmpty()) return; + try { + new ReminderRescheduler(context, database) + .reconcileNotes(ids, System.currentTimeMillis()); + } catch (RuntimeException error) { + // The data is committed; alarms are re-armed from it again at the next app start. + Log.w(TAG, "Re-arming reminders after sync failed", error); } } @@ -1457,7 +1598,10 @@ private void applyResolvedRecord( database.syncMetadataDao() .getByStableId(conflict.recordType, conflict.stableId); if (preferencesMetadata != null) { - applyPayload(preferencesMetadata, selected.getPayload()); + applyPayload( + preferencesMetadata, + selected.getPayload(), + NoteVersionReason.PRE_CONFLICT); database.syncMetadataDao() .setVersion( conflict.recordType, @@ -1469,7 +1613,7 @@ private void applyResolvedRecord( return; } - applyPayload(metadata, selected.getPayload()); + applyPayload(metadata, selected.getPayload(), NoteVersionReason.PRE_CONFLICT); database.syncMetadataDao() .setVersion(conflict.recordType, metadata.localId, updatedAt, null); } diff --git a/app/src/main/java/com/pasich/mynotes/data/sync/SyncMetadata.java b/app/src/main/java/com/pasich/mynotes/data/sync/SyncMetadata.java index f133508d..b3671128 100644 --- a/app/src/main/java/com/pasich/mynotes/data/sync/SyncMetadata.java +++ b/app/src/main/java/com/pasich/mynotes/data/sync/SyncMetadata.java @@ -32,6 +32,10 @@ public static String newStableId() { *

Identity travels in the record's stable ID and attachments travel in the bundle manifest, * so nothing here is needed on the wire. Applied to decoded remote records as well, so bundles * written by 2.6.48/2.6.49 normalize to the same shape instead of conflicting forever. + * + *

A note's custom position is stripped for a different reason: it is this device's own + * arrangement, changed without touching the record, so carrying it would turn every drag into + * an edit of the note on every device, and clients that predate it would see it as a change. */ public static void stripDeviceLocalFields( String recordType, com.google.gson.JsonObject payload) { @@ -41,6 +45,7 @@ public static void stripDeviceLocalFields( if (RECORD_TYPE_NOTE.equals(recordType)) { payload.remove("a"); // Note.id payload.remove("h"); // Note.attachments: device-local file:// paths + payload.remove("n"); // Note.customPosition: this device's own manual order } else if (RECORD_TYPE_TAG.equals(recordType)) { payload.remove("a"); // Tag.id } else if (RECORD_TYPE_TASK.equals(recordType)) { diff --git a/app/src/main/java/com/pasich/mynotes/data/sync/SyncMutationCoordinator.java b/app/src/main/java/com/pasich/mynotes/data/sync/SyncMutationCoordinator.java index 47f42e9e..74d2b263 100644 --- a/app/src/main/java/com/pasich/mynotes/data/sync/SyncMutationCoordinator.java +++ b/app/src/main/java/com/pasich/mynotes/data/sync/SyncMutationCoordinator.java @@ -8,11 +8,16 @@ import com.pasich.mynotes.data.database.dao.TaskCategoryDao; import com.pasich.mynotes.data.database.dao.TaskDao; import com.pasich.mynotes.data.database.dao.Transactions; +import com.pasich.mynotes.data.database.entities.NoteVersionEntity; import com.pasich.mynotes.data.database.entities.SyncMetadataEntity; +import com.pasich.mynotes.data.history.NoteHistory; +import com.pasich.mynotes.data.history.NoteVersionReason; +import com.pasich.mynotes.data.history.NoteVersionRestore; import com.pasich.mynotes.data.model.Note; import com.pasich.mynotes.data.model.Tag; import com.pasich.mynotes.data.model.Task; import com.pasich.mynotes.data.model.TaskCategory; +import com.pasich.mynotes.data.order.CustomOrder; import java.util.ArrayList; import java.util.Collections; import java.util.LinkedHashSet; @@ -67,6 +72,7 @@ interface AttachmentRelocation { private final TimeProvider timeProvider; private final StableIdGenerator stableIdGenerator; private final AttachmentRelocation attachmentRelocation; + private final NoteHistory noteHistory; private final Object legacyImportLock = new Object(); private long legacyImportTimestamp = -1L; private long legacyImportExpiresAt = -1L; @@ -114,7 +120,15 @@ public T call() { note.setValueJson(moved.valueJson); } return moved.changed; - }); + }, + new NoteHistory( + database.noteVersionDao(), + noteId -> { + SyncMetadataEntity metadata = + database.syncMetadataDao() + .get(SyncMetadata.RECORD_TYPE_NOTE, noteId); + return metadata == null ? null : metadata.stableId; + })); } SyncMutationCoordinator( @@ -151,7 +165,34 @@ public T call() { @NonNull TimeProvider timeProvider, @NonNull StableIdGenerator stableIdGenerator, @NonNull AttachmentRelocation attachmentRelocation) { + this( + transactionExecutor, + noteDao, + taskDao, + tagsDao, + taskCategoryDao, + transactions, + syncMetadataDao, + timeProvider, + stableIdGenerator, + attachmentRelocation, + NoteHistory.NONE); + } + + SyncMutationCoordinator( + @NonNull TransactionExecutor transactionExecutor, + @NonNull NoteDao noteDao, + @NonNull TaskDao taskDao, + @NonNull TagsDao tagsDao, + @NonNull TaskCategoryDao taskCategoryDao, + @NonNull Transactions transactions, + @NonNull SyncMetadataDao syncMetadataDao, + @NonNull TimeProvider timeProvider, + @NonNull StableIdGenerator stableIdGenerator, + @NonNull AttachmentRelocation attachmentRelocation, + @NonNull NoteHistory noteHistory) { this.attachmentRelocation = attachmentRelocation; + this.noteHistory = noteHistory; this.transactionExecutor = transactionExecutor; this.noteDao = noteDao; this.taskDao = taskDao; @@ -307,6 +348,7 @@ public void insertNotes(List incoming) { List notes = withoutNotesAlreadyPresent(incoming, existingById); adoptAttachmentsOfNotesAlreadyPresent(incoming, notes); if (notes.isEmpty()) return null; + placeUnorderedNotesOnTop(notes); long timestamp = resolveBatchTimestamp( SyncMetadata.RECORD_TYPE_NOTE, extractNoteIds(notes)); @@ -395,9 +437,15 @@ private void assignInsertedNoteIds( } } + /** + * Stores the editor's content for a note, first keeping the content it replaces in the note's + * history when that is due (see {@link NoteHistory#recordBeforeEdit}). + */ public void updateNoteContent(@NonNull Note note) { transactionExecutor.run( () -> { + noteHistory.recordBeforeEdit( + noteDao.getNoteSync(note.getId()), note, timeProvider.now()); noteDao.updateNoteContent( note.getId(), note.getTitle(), @@ -411,10 +459,55 @@ public void updateNoteContent(@NonNull Note note) { }); } + /** + * Puts one of a note's earlier versions back as its current content. + * + *

The content being replaced is kept first, so a restore can itself be undone, and the + * restore is then stored as an ordinary edit that syncs like any other. Only text comes back; + * see {@link NoteVersionRestore}. + * + * @return false when the note or the version no longer exists. + */ + public boolean restoreNoteVersion(int noteId, long versionId) { + return transactionExecutor.run( + () -> { + NoteVersionEntity version = noteHistory.get(versionId); + Note stored = noteDao.getNoteSync(noteId); + if (version == null || stored == null || version.noteLocalId != noteId) { + return false; + } + Note restored = new Note(); + restored.copyFrom(stored); + restored.setId(stored.getId()); + NoteVersionRestore.plan(stored, version).applyTo(restored); + if (NoteHistory.sameText(stored, restored)) return true; + + long now = timeProvider.now(); + noteHistory.recordBeforeOverwrite( + stored, restored, NoteVersionReason.PRE_RESTORE, now); + noteDao.updateNoteContent( + noteId, + restored.getTitle(), + restored.getValue(), + restored.getValueJson(), + now, + restored.getTag(), + restored.getAttachments()); + touchRecord(SyncMetadata.RECORD_TYPE_NOTE, noteId, now); + return true; + }); + } + + /** Removes history left without its note; returns how many versions went. */ + public int sweepOrphanNoteVersions() { + return transactionExecutor.run(noteHistory::sweepOrphans); + } + public void deleteNote(@NonNull Note note) { transactionExecutor.run( () -> { noteDao.deleteNote(note); + noteHistory.forget(Collections.singletonList(note.getId())); markDeletedRecord( SyncMetadata.RECORD_TYPE_NOTE, note.getId(), timeProvider.now()); return null; @@ -426,10 +519,13 @@ public void deleteNotes(List notes) { transactionExecutor.run( () -> { long timestamp = timeProvider.now(); + List deletedIds = new ArrayList<>(notes.size()); for (Note note : notes) { noteDao.deleteNote(note); + deletedIds.add(note.getId()); markDeletedRecord(SyncMetadata.RECORD_TYPE_NOTE, note.getId(), timestamp); } + noteHistory.forget(deletedIds); return null; }); } @@ -464,15 +560,21 @@ public void restoreNotesFromTrash(List noteIds) { }); } - public void deleteAllTrashNotes() { - transactionExecutor.run( + /** + * Deletes every note in the trash. + * + * @return the ids of the deleted notes. + */ + public List deleteAllTrashNotes() { + return transactionExecutor.run( () -> { List trashNoteIds = noteDao.getTrashNoteIdsSync(); - if (trashNoteIds.isEmpty()) return null; + if (trashNoteIds.isEmpty()) return Collections.emptyList(); long timestamp = timeProvider.now(); noteDao.deleteAllTrashNotes(); + noteHistory.forget(trashNoteIds); markDeletedRecords(SyncMetadata.RECORD_TYPE_NOTE, trashNoteIds, timestamp); - return null; + return trashNoteIds; }); } @@ -664,7 +766,124 @@ public void deleteCategory(@NonNull TaskCategory category) { }); } + /** + * Deletes every note (trashed ones included), task, user tag and task category in one + * transaction, leaving a tombstone for each so a sync propagates the deletion instead of + * restoring the records from the remote copy. Attachment files and alarms are the caller's. + */ + public void clearAllUserData() { + transactionExecutor.run( + () -> { + long timestamp = timeProvider.now(); + List noteIds = noteDao.getAllNoteIdsSync(); + noteDao.deleteAllNotes(); + noteHistory.forgetAll(); + markDeletedRecords(SyncMetadata.RECORD_TYPE_NOTE, noteIds, timestamp); + + List taskIds = taskDao.getAllTaskIdsSync(); + taskDao.deleteAllTasks(); + markDeletedRecords(SyncMetadata.RECORD_TYPE_TASK, taskIds, timestamp); + + List tags = tagsDao.getUserTagsSync(); + tagsDao.deleteUserTags(); + if (tags != null) { + for (Tag tag : tags) { + markDeletedRecord(SyncMetadata.RECORD_TYPE_TAG, tag.getId(), timestamp); + } + } + + List categories = taskCategoryDao.getCategoriesSync(); + taskCategoryDao.deleteAllCategories(); + if (categories != null) { + for (TaskCategory category : categories) { + markDeletedRecord( + SyncMetadata.RECORD_TYPE_CATEGORY, category.getId(), timestamp); + } + } + return null; + }); + } + + /** + * Gives notes that arrive without a place in the custom order — an import, a backup from before + * the order existed — places above every note, the most recently edited highest, so they come + * in the way a new note does. + */ + private void placeUnorderedNotesOnTop(@NonNull List notes) { + List unordered = new ArrayList<>(); + for (Note note : notes) { + if (note.getCustomPosition() == 0L) unordered.add(note); + } + if (unordered.isEmpty()) return; + unordered.sort( + (a, b) -> { + int byDate = Long.compare(a.getDate(), b.getDate()); + return byDate != 0 ? byDate : Integer.compare(a.getId(), b.getId()); + }); + long highest = noteDao.getHighestCustomPositionSync(); + for (Note note : notes) highest = Math.max(highest, note.getCustomPosition()); + for (Note note : unordered) { + highest = CustomOrder.above(highest); + note.setCustomPosition(highest); + } + } + + /** + * Moves a note in the custom order to between two neighbours. + * + *

A local arrangement, not an edit: the note's content, date and sync timestamp stay as they + * are, so nothing is published and no other device sees a change. + * + * @param upperId the note that will be just above it, or null when it goes to the top. + * @param lowerId the note that will be just below it, or null when it goes to the bottom. + */ + public void moveNoteInCustomOrder( + int noteId, + @androidx.annotation.Nullable Integer upperId, + @androidx.annotation.Nullable Integer lowerId) { + transactionExecutor.run( + () -> { + if (noteDao.getCustomPositionSync(noteId) == null) return null; + Long position = positionBetween(upperId, lowerId); + if (position == null) { + renumberCustomOrder(); + position = positionBetween(upperId, lowerId); + } + if (position != null) noteDao.setCustomPositionSync(noteId, position); + return null; + }); + } + + @androidx.annotation.Nullable + private Long positionBetween( + @androidx.annotation.Nullable Integer upperId, + @androidx.annotation.Nullable Integer lowerId) { + Long upper = upperId == null ? null : noteDao.getCustomPositionSync(upperId); + Long lower = lowerId == null ? null : noteDao.getCustomPositionSync(lowerId); + return CustomOrder.between(upper, lower); + } + + /** Spreads every note {@link CustomOrder#STEP} apart again, keeping their order. */ + private void renumberCustomOrder() { + List all = new ArrayList<>(noteDao.getAllNotesSync()); + all.sort( + (a, b) -> { + int byPosition = Long.compare(b.getCustomPosition(), a.getCustomPosition()); + return byPosition != 0 ? byPosition : Integer.compare(b.getId(), a.getId()); + }); + List positions = CustomOrder.renumbered(all.size()); + for (int i = 0; i < all.size(); i++) { + if (all.get(i).getCustomPosition() != positions.get(i)) { + noteDao.setCustomPositionSync(all.get(i).getId(), positions.get(i)); + } + } + } + private long insertNoteInternal(@NonNull Note note, long timestamp) { + if (note.getCustomPosition() == 0L) { + // A new note goes to the top of the custom order. + note.setCustomPosition(CustomOrder.above(noteDao.getHighestCustomPositionSync())); + } long insertedId = noteDao.addNote(note); int localId = resolveIntId(note.getId(), insertedId); note.setId(localId); diff --git a/app/src/main/java/com/pasich/mynotes/di/ApplicationModule.java b/app/src/main/java/com/pasich/mynotes/di/ApplicationModule.java index e72cd797..4edb33af 100644 --- a/app/src/main/java/com/pasich/mynotes/di/ApplicationModule.java +++ b/app/src/main/java/com/pasich/mynotes/di/ApplicationModule.java @@ -78,7 +78,8 @@ AppDatabase providesAppDatabase(@ApplicationContext Context context) { AppDatabase.MIGRATION_18_19, AppDatabase.MIGRATION_19_20, AppDatabase.MIGRATION_20_21, - AppDatabase.MIGRATION_21_22) + AppDatabase.MIGRATION_21_22, + AppDatabase.MIGRATION_22_23) .build(); } diff --git a/app/src/main/java/com/pasich/mynotes/di/activity/ListUtilsModule.java b/app/src/main/java/com/pasich/mynotes/di/activity/ListUtilsModule.java index 2dffa67a..447e43cf 100644 --- a/app/src/main/java/com/pasich/mynotes/di/activity/ListUtilsModule.java +++ b/app/src/main/java/com/pasich/mynotes/di/activity/ListUtilsModule.java @@ -4,9 +4,9 @@ import androidx.recyclerview.widget.DiffUtil; import androidx.recyclerview.widget.LinearLayoutManager; import androidx.recyclerview.widget.RecyclerView; -import androidx.recyclerview.widget.StaggeredGridLayoutManager; import com.pasich.mynotes.cache.AppPreferencesCache; import com.pasich.mynotes.data.model.Tag; +import com.pasich.mynotes.utils.recycler.NotesGridLayoutManager; import com.pasich.mynotes.utils.recycler.SpacesItemDecoration; import com.pasich.mynotes.utils.recycler.diffutil.DiffUtilTag; import dagger.Module; @@ -29,8 +29,8 @@ SpacesItemDecoration providerSpaceItemDecorationNotes() { @Provides @ActivityScoped - StaggeredGridLayoutManager providesStaggeredGridLayoutManager(int spanCount) { - return new StaggeredGridLayoutManager(spanCount, StaggeredGridLayoutManager.VERTICAL); + NotesGridLayoutManager providesNotesGridLayoutManager(int spanCount) { + return new NotesGridLayoutManager(spanCount); } @Named("Tag") diff --git a/app/src/main/java/com/pasich/mynotes/extendedEditor/NoteEditorView.java b/app/src/main/java/com/pasich/mynotes/extendedEditor/NoteEditorView.java index bbd03a35..018db830 100644 --- a/app/src/main/java/com/pasich/mynotes/extendedEditor/NoteEditorView.java +++ b/app/src/main/java/com/pasich/mynotes/extendedEditor/NoteEditorView.java @@ -1,7 +1,9 @@ package com.pasich.mynotes.extendedEditor; import android.annotation.SuppressLint; +import android.app.Activity; import android.content.Context; +import android.content.ContextWrapper; import android.content.Intent; import android.net.Uri; import android.os.Handler; @@ -11,20 +13,32 @@ import android.view.View; import android.view.ViewGroup; import android.view.ViewParent; +import android.view.Window; +import android.view.inputmethod.InputMethodManager; import android.webkit.ValueCallback; import android.webkit.WebChromeClient; import android.webkit.WebSettings; import android.webkit.WebView; import android.widget.FrameLayout; import android.widget.Toast; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import androidx.core.view.ViewCompat; +import androidx.core.view.WindowCompat; +import androidx.core.view.WindowInsetsCompat; +import com.google.android.material.color.MaterialColors; import com.pasich.mynotes.R; import com.pasich.mynotes.data.model.Note; import com.pasich.mynotes.extendedEditor.attach.AttachmentStorage; +import com.pasich.mynotes.extendedEditor.models.PickedFile; import com.pasich.mynotes.extendedEditor.utils.EditorAttachmentsWebViewClient; import com.pasich.mynotes.extendedEditor.utils.EditorJSInterface; import com.pasich.mynotes.extendedEditor.utils.SettingsEditorColors; +import com.pasich.mynotes.utils.editor.KeyboardRequest; +import java.util.ArrayList; +import java.util.List; import java.util.Locale; +import java.util.function.LongFunction; public class NoteEditorView extends FrameLayout { @@ -35,11 +49,42 @@ public class NoteEditorView extends FrameLayout { private EditorJSInterface editorInterface; private Handler handler; private Note pendingNote; + // Block at the top of the viewport, as last reported by the editor. + private int anchorIndex = -1; + private int anchorOffset = 0; + // Reading position to restore on the next load (after a recreation). + private int restoreAnchorIndex = -1; + private int restoreAnchorOffset = 0; + // A saved position resolved for the next load, in the page's form. + private String restoreViewState; + // Where the note is being read or edited, as last reported by the page; null until the page + // has shown the note and applied any saved position. + private String lastViewState; + private boolean autofocus = true; + private boolean startReadOnly = false; + private boolean doubleTapToEdit = false; + // Put the caret at the start of the next loaded note ("open in editing mode"). + private boolean focusStart = false; private boolean editorIsReady = false; private boolean htmlLoaded = false; private OnFileChooserListener fileChooserListener; private OnContextDialogListener onContextDialogListener; private ValueCallback fileCallback; + // What the open picker was asked for and where its block goes. Kept in the activity's saved + // state, so a result that comes back to a recreated screen still lands in the right place. + private String chooserKind; + private int chooserBlockIndex = -1; + // Files picked for a page that no longer exists, inserted once the note is rendered again. + private final List orphanedPicks = new ArrayList<>(); + private String orphanedKind; + private int orphanedIndex = -1; + private boolean noteRendered = false; + // Undo history handed over by the page of the screen this one replaced; asked once, for the + // first note loaded. + private LongFunction handedOverHistory; + private boolean releasing = false; + private final KeyboardRequest keyboardRequest = new KeyboardRequest(); + private final Runnable keyboardStep = this::continueKeyboardRequest; public NoteEditorView(Context context, AttributeSet attrs) { super(context, attrs); @@ -78,9 +123,37 @@ protected void onAttachedToWindow() { /** Loads the Editor.js HTML page from the app assets with the current locale. */ private void loadEditorHtml() { if (webView == null) return; - webView.loadUrl( + // The app's language, which the page's hints and tool names follow. + Locale locale = getResources().getConfiguration().getLocales().get(0); + String url = "file:///android_asset/editor/editor.html?locale=" - + Locale.getDefault().getLanguage()); + + (locale != null ? locale : Locale.getDefault()).getLanguage(); + if (!autofocus) url += "&autofocus=0"; + if (startReadOnly) url += "&readonly=1"; + if (doubleTapToEdit) url += "&dbltap=1"; + webView.loadUrl(url); + } + + /** + * How the page starts: in reading mode or not, and whether a double tap in reading mode starts + * editing there. Takes effect only before the view is attached. + */ + public void setStartOptions(boolean readOnly, boolean doubleTapToEdit) { + this.startReadOnly = readOnly; + this.doubleTapToEdit = doubleTapToEdit; + } + + /** Puts the caret at the start of the next note loaded, unless a saved position places it. */ + public void setFocusStart(boolean focusStart) { + this.focusStart = focusStart; + } + + /** + * Whether the editor puts the caret in the first block as it starts; on unless a saved position + * is about to put it back. Takes effect only before the view is attached. + */ + public void setAutofocus(boolean autofocus) { + this.autofocus = autofocus; } /** @@ -102,6 +175,12 @@ private void setupWebView() { webView.setLayerType(View.LAYER_TYPE_NONE, null); + // The page is transparent until the theme reaches it; paint the theme's surface from the + // first frame so the editor never flashes white, least of all in the dark theme. + webView.setBackgroundColor( + MaterialColors.getColor( + webView, com.google.android.material.R.attr.colorSurfaceContainerLow)); + webView.setVerticalScrollBarEnabled(false); webView.setHorizontalScrollBarEnabled(false); webView.setWebViewClient(new EditorAttachmentsWebViewClient(getContext())); @@ -112,21 +191,28 @@ public boolean onShowFileChooser( WebView webView, ValueCallback filePathCallback, FileChooserParams fileChooserParams) { - fileCallback = filePathCallback; + if (fileChooserListener == null) return false; Intent intent; try { intent = fileChooserParams.createIntent(); } catch (Exception e) { - fileCallback = null; return false; } - - if (fileChooserListener != null) { - fileChooserListener.onOpenFileChooser(intent, FILE_CHOOSER_REQUEST); - return true; - } - - return false; + fileCallback = filePathCallback; + chooserKind = kindFor(fileChooserParams.getAcceptTypes()); + chooserBlockIndex = -1; + // Remember which block asked before the picker covers the screen, then + // open it. + webView.evaluateJavascript( + "window.currentBlockIndex ? currentBlockIndex() : -1", + value -> { + chooserBlockIndex = parseIndex(value); + if (fileChooserListener != null) { + fileChooserListener.onOpenFileChooser( + intent, FILE_CHOOSER_REQUEST); + } + }); + return true; } }); @@ -145,6 +231,23 @@ public WebView getWebView() { return webView; } + private static String kindFor(String[] acceptTypes) { + if (acceptTypes != null) { + for (String type : acceptTypes) { + if (type != null && type.startsWith("image/")) return EditorJSInterface.KIND_IMAGE; + } + } + return EditorJSInterface.KIND_FILE; + } + + private static int parseIndex(String value) { + try { + return value == null ? -1 : (int) Double.parseDouble(value); + } catch (NumberFormatException e) { + return -1; + } + } + public void onEditorReadyFromBridge() { handler.post( () -> { @@ -152,7 +255,7 @@ public void onEditorReadyFromBridge() { applyTheme(); if (pendingNote != null) { if (editorInterface != null) { - editorInterface.loadNoteToEditor(pendingNote); + loadIntoEditor(pendingNote); } else { Log.w( TAG, @@ -193,9 +296,93 @@ void showEditor() { /** Toggles read-only mode inside Editor.js (title and blocks become non-editable). */ public void actionRead() { if (!editorIsReady) return; + keyboardRequest.cancel(); editorInterface.toggleReadMode(); } + /** Whether the page has finished starting, so a mode switch can reach it. */ + public boolean isEditorReady() { + return editorIsReady; + } + + /** + * Focuses the page and opens the keyboard on its caret, giving the page a caret first when it + * has none. The keyboard is asked for only once the page's editable field holds the focus, and + * is then connected to it afresh: asked for earlier, it can come up attached to nothing. A + * request made while the screen is still opening waits for window focus, and is repeated until + * the keyboard is up ({@link KeyboardRequest}). + */ + public void showKeyboard() { + if (webView == null || releasing) return; + keyboardRequest.start(); + handler.removeCallbacks(keyboardStep); + continueKeyboardRequest(); + } + + private void continueKeyboardRequest() { + if (webView == null || releasing) return; + WindowInsetsCompat insets = ViewCompat.getRootWindowInsets(webView); + boolean visible = insets != null && insets.isVisible(WindowInsetsCompat.Type.ime()); + // The WebView reports a text editor only while an editable field of the page is focused. + boolean editorFocused = webView.hasFocus() && webView.onCheckIsTextEditor(); + InputMethodManager imm = + (InputMethodManager) getContext().getSystemService(Context.INPUT_METHOD_SERVICE); + switch (keyboardRequest.next(visible, webView.hasWindowFocus(), editorFocused)) { + case DONE, WAIT_FOR_WINDOW_FOCUS -> { + // Done, or continued from onWindowFocusChanged. + } + case FOCUS_AND_RETRY -> { + // The view first, so the page is focused when it moves the focus to its field. + if (!webView.hasFocus()) webView.requestFocus(); + if (editorInterface != null) editorInterface.focusCaret(); + handler.postDelayed(keyboardStep, KeyboardRequest.RETRY_MS); + } + case CONNECT_AND_SHOW -> { + if (imm != null) imm.restartInput(webView); + askForKeyboard(imm); + } + case SHOW_AND_RETRY -> askForKeyboard(imm); + } + } + + private void askForKeyboard(@Nullable InputMethodManager imm) { + Window window = findWindow(); + if (window != null) { + WindowCompat.getInsetsController(window, webView).show(WindowInsetsCompat.Type.ime()); + } + if (imm != null) imm.showSoftInput(webView, InputMethodManager.SHOW_IMPLICIT); + handler.postDelayed(keyboardStep, KeyboardRequest.RETRY_MS); + } + + @Nullable + private Window findWindow() { + Context context = getContext(); + while (context instanceof ContextWrapper wrapper) { + if (context instanceof Activity activity) return activity.getWindow(); + context = wrapper.getBaseContext(); + } + return null; + } + + @Override + public void onWindowFocusChanged(boolean hasWindowFocus) { + super.onWindowFocusChanged(hasWindowFocus); + if (hasWindowFocus && keyboardRequest.isPending()) { + handler.removeCallbacks(keyboardStep); + continueKeyboardRequest(); + } + } + + /** Takes back the last change in the note; the page saves the result like any edit. */ + public void undo() { + if (editorIsReady && editorInterface != null) editorInterface.undo(); + } + + /** Applies the last undone change again. */ + public void redo() { + if (editorIsReady && editorInterface != null) editorInterface.redo(); + } + public void deleteBlock(String blockId, String fileUrl) { if (!editorIsReady) return; editorInterface.deleteAttachmentBlockRequest(blockId, fileUrl); @@ -211,20 +398,109 @@ public void load(Note mNote) { } if (editorIsReady) { - editorInterface.loadNoteToEditor(mNote); + loadIntoEditor(mNote); } else { pendingNote = mNote; } } + private void loadIntoEditor(Note note) { + lastViewState = null; + LongFunction history = handedOverHistory; + handedOverHistory = null; + editorInterface.loadNoteToEditor( + note, + restoreAnchorIndex, + restoreAnchorOffset, + restoreViewState, + focusStart, + history != null ? history.apply(note.getId()) : null); + restoreAnchorIndex = -1; + restoreAnchorOffset = 0; + restoreViewState = null; + focusStart = false; + } + + /** + * Where the undo history of the page this screen replaced can be found, by note id; asked when + * the first note is loaded. + */ + public void setHandedOverHistory(LongFunction source) { + handedOverHistory = source; + } + + /** + * A saved position to apply once the next note is rendered ({@code + * ExtendedViewStateJson.toPage}); it takes the place of the reading anchor. + */ + public void setRestoreViewState(String viewState) { + restoreViewState = viewState; + } + + /** Remembers where the note is being read or edited, as reported by the page. */ + public void onViewState(String json) { + lastViewState = json; + } + + /** + * Asks the page where the note is right now, without waiting for its next report; {@code + * callback} gets the page's JSON, or null when it has none. The answer is also kept as the last + * position. + */ + public void readViewState(@NonNull ValueCallback callback) { + if (!editorIsReady || editorInterface == null || releasing) { + callback.onReceiveValue(null); + return; + } + editorInterface.readViewState( + json -> { + if (json != null) lastViewState = json; + callback.onReceiveValue(json); + }); + } + + /** The last position the page reported, or null when it has not reported one yet. */ + public String getLastViewState() { + return lastViewState; + } + + /** Remembers the block at the top of the viewport, as reported by the editor. */ + public void onViewportAnchor(int blockIndex, int offsetPx) { + anchorIndex = blockIndex; + anchorOffset = offsetPx; + } + + public int getAnchorIndex() { + return anchorIndex; + } + + public int getAnchorOffset() { + return anchorOffset; + } + + /** Reading position to scroll to once the next note has been rendered. */ + public void setRestoreAnchor(int blockIndex, int offsetPx) { + restoreAnchorIndex = blockIndex; + restoreAnchorOffset = offsetPx; + anchorIndex = blockIndex; + anchorOffset = offsetPx; + } + + /** Asks the editor for its document right away instead of after its change batching. */ + public void requestFlush() { + if (editorIsReady && editorInterface != null) editorInterface.requestFlush(); + } + /** * Handles result from WebView file chooser, including validation (size limit, free space), * before passing the file to JS. */ public void onFileChooserResult(int resultCode, Intent data) { - if (fileCallback == null) return; - Uri[] result = WebChromeClient.FileChooserParams.parseResult(resultCode, data); + String kind = chooserKind; + int index = chooserBlockIndex; + chooserKind = null; + chooserBlockIndex = -1; if (result != null && result.length > 0) { @@ -235,16 +511,83 @@ public void onFileChooserResult(int resultCode, Intent data) { if (!validation.ok) { Toast.makeText(getContext(), validation.error, Toast.LENGTH_SHORT).show(); - fileCallback.onReceiveValue(null); - fileCallback = null; - return; + result = null; } } + List picked = new ArrayList<>(); + if (result != null) { + for (Uri uri : result) { + picked.add( + new PickedFile( + uri, + AttachmentStorage.getDisplayName(getContext(), uri), + AttachmentStorage.getFileSize(getContext(), uri))); + } + } + + if (fileCallback == null) { + // The screen was recreated while the picker was open: the page that asked is gone. + // Store the file and put its block back where the old page had it. + if (!picked.isEmpty() && kind != null) { + orphanedPicks.clear(); + orphanedPicks.addAll(picked); + orphanedKind = kind; + orphanedIndex = index; + insertOrphanedPicks(); + } + return; + } + + if (editorInterface != null) editorInterface.offerPickedFiles(picked); fileCallback.onReceiveValue(result); fileCallback = null; } + /** Called when the page has rendered a note; picks waiting for it are inserted now. */ + public void onNoteRenderedFromBridge() { + handler.post( + () -> { + noteRendered = true; + insertOrphanedPicks(); + }); + } + + private void insertOrphanedPicks() { + if (!noteRendered || editorInterface == null || orphanedPicks.isEmpty()) return; + String kind = orphanedKind; + int index = orphanedIndex; + for (PickedFile file : orphanedPicks) { + editorInterface.uploadAndInsert( + kind, + file, + index, + () -> + Toast.makeText( + getContext(), + R.string.attachment_save_failed, + Toast.LENGTH_SHORT) + .show()); + if (index >= 0) index++; + } + orphanedPicks.clear(); + } + + /** The open picker's kind, to keep in the saved state; null when no picker is open. */ + public String getChooserKind() { + return chooserKind; + } + + public int getChooserBlockIndex() { + return chooserBlockIndex; + } + + /** Restores what a picker opened by the previous instance was asked for. */ + public void restoreChooserState(String kind, int blockIndex) { + chooserKind = kind; + chooserBlockIndex = blockIndex; + } + @Override protected void onDetachedFromWindow() { super.onDetachedFromWindow(); @@ -258,10 +601,33 @@ protected void onDetachedFromWindow() { } /** - * Fully and safely destroys the WebView instance to prevent memory leaks. Must be called from + * Destroys the WebView once the page has handed over its last edit. Must be called from * Activity/Fragment onDestroy(). */ public void release() { + release(false); + } + + /** + * Destroys the WebView once the page has handed over its last edit and, with {@code + * keepHistory} (the screen is being recreated), its undo history. The page answers + * asynchronously, so the WebView is kept until it has, for a short time at most; destroying it + * at once lost the last edit and made a flush still on its way reach a destroyed WebView. + */ + public void release(boolean keepHistory) { + if (releasing) return; + releasing = true; + if (handler != null) handler.removeCallbacksAndMessages(null); + if (editorIsReady && editorInterface != null && webView != null) { + editorInterface.finishPage(keepHistory, this::destroyWebView); + } else { + destroyWebView(); + } + } + + private void destroyWebView() { + // Before the WebView goes: nothing still queued may reach it afterwards. + if (editorInterface != null) editorInterface.release(); try { if (webView != null) { diff --git a/app/src/main/java/com/pasich/mynotes/extendedEditor/attach/AttachmentCleaner.java b/app/src/main/java/com/pasich/mynotes/extendedEditor/attach/AttachmentCleaner.java index e14cf309..608a504a 100644 --- a/app/src/main/java/com/pasich/mynotes/extendedEditor/attach/AttachmentCleaner.java +++ b/app/src/main/java/com/pasich/mynotes/extendedEditor/attach/AttachmentCleaner.java @@ -17,6 +17,7 @@ import java.util.HashSet; import java.util.List; import java.util.Set; +import java.util.concurrent.TimeUnit; /** * Keeps a note's attachment folder consistent with its attachments JSON. @@ -34,6 +35,13 @@ public class AttachmentCleaner { private static final String TAG = "AttachmentCleaner"; private static final Gson gson = new Gson(); + /** + * Files younger than this are never treated as orphans. A just-uploaded file is referenced only + * by a block that has not been saved yet; {@link RecentAttachmentUploads} covers that within + * this process, and the age rule covers a process that died before the save. + */ + public static final long GRACE_PERIOD_MS = TimeUnit.MINUTES.toMillis(10); + /** Outcome of one cleanup pass; {@code ABORTED_*} guarantees nothing was deleted. */ public enum Result { /** Orphans were considered and any that existed were removed. */ @@ -69,7 +77,9 @@ public static Result cleanup(Context ctx, Note note) { return cleanup( new File(ctx.getFilesDir(), ATTACHMENTS_BASE_DIR), note.getId(), - note.getAttachments()); + note.getAttachments(), + RecentAttachmentUploads.protectedNames(note.getId()), + System.currentTimeMillis()); } /** @@ -82,6 +92,23 @@ public static Result cleanup(Context ctx, Note note) { @NonNull static Result cleanup( @NonNull File attachmentsRoot, int noteId, @Nullable String attachmentsJson) { + return cleanup(attachmentsRoot, noteId, attachmentsJson, new HashSet<>(), Long.MIN_VALUE); + } + + /** + * As {@link #cleanup(File, int, String)}, but never deletes a file in {@code recentUploads} or + * one modified within {@link #GRACE_PERIOD_MS} before {@code nowMillis}. + * + * @param recentUploads names uploaded in this process whose block may not be saved yet. + * @param nowMillis the current time; {@link Long#MIN_VALUE} disables the age rule. + */ + @NonNull + static Result cleanup( + @NonNull File attachmentsRoot, + int noteId, + @Nullable String attachmentsJson, + @NonNull Set recentUploads, + long nowMillis) { List referenced; try { Type type = new TypeToken>() {}.getType(); @@ -123,14 +150,28 @@ static Result cleanup( return Result.ABORTED_UNRESOLVED_REFERENCE; } + RecentAttachmentUploads.settle(noteId, expected); for (File candidate : actualFiles) { if (!candidate.isFile() || expected.contains(candidate.getName())) continue; + if (recentUploads.contains(candidate.getName())) { + d("Kept recent upload: " + candidate.getName()); + continue; + } + if (isWithinGracePeriod(candidate, nowMillis)) { + d("Kept file inside the grace period: " + candidate.getName()); + continue; + } boolean deleted = candidate.delete(); w("Orphan deleted: " + candidate.getName() + " -> " + deleted); } return Result.CLEANED; } + private static boolean isWithinGracePeriod(File file, long nowMillis) { + if (nowMillis == Long.MIN_VALUE) return false; + return file.lastModified() > nowMillis - GRACE_PERIOD_MS; + } + public static void deleteAttachmentFolderByNoteId(Context ctx, long noteId) { try { File base = new File(ctx.getFilesDir(), ATTACHMENTS_BASE_DIR); diff --git a/app/src/main/java/com/pasich/mynotes/extendedEditor/attach/AttachmentStorage.java b/app/src/main/java/com/pasich/mynotes/extendedEditor/attach/AttachmentStorage.java index a34fb3a4..30ea2616 100644 --- a/app/src/main/java/com/pasich/mynotes/extendedEditor/attach/AttachmentStorage.java +++ b/app/src/main/java/com/pasich/mynotes/extendedEditor/attach/AttachmentStorage.java @@ -9,8 +9,10 @@ import com.pasich.mynotes.R; import com.pasich.mynotes.extendedEditor.models.EditorAttachment; import com.pasich.mynotes.utils.file.ImageOptimizer; +import java.io.ByteArrayOutputStream; import java.io.File; import java.io.FileOutputStream; +import java.io.InputStream; /** * Utility class for managing note attachments stored in the app's internal storage. Handles @@ -69,6 +71,41 @@ public static long getFileSize(Context ctx, Uri uri) { return -1; } + /** The display name a content provider reports for {@code uri}, or null. */ + public static String getDisplayName(Context ctx, Uri uri) { + try (Cursor cursor = ctx.getContentResolver().query(uri, null, null, null, null)) { + if (cursor != null && cursor.moveToFirst()) { + int nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME); + if (nameIndex != -1) return cursor.getString(nameIndex); + } + } catch (Exception ignored) { + } + return null; + } + + /** + * Reads a picked file's bytes. Returns null when it cannot be read or is larger than {@link + * #MAX_FILE_SIZE}. Blocking: never call it on the main thread. + */ + public static byte[] readUri(Context ctx, Uri uri) { + try (InputStream in = ctx.getContentResolver().openInputStream(uri)) { + if (in == null) return null; + ByteArrayOutputStream out = new ByteArrayOutputStream(); + byte[] buffer = new byte[64 * 1024]; + long total = 0; + int read; + while ((read = in.read(buffer)) != -1) { + total += read; + if (total > MAX_FILE_SIZE) return null; + out.write(buffer, 0, read); + } + return out.toByteArray(); + } catch (Exception e) { + Log.e(TAG, "readUri() failed", e); + return null; + } + } + /** * Checks if internal storage has the required amount of free space. * diff --git a/app/src/main/java/com/pasich/mynotes/extendedEditor/attach/EditorAttachmentBlocks.java b/app/src/main/java/com/pasich/mynotes/extendedEditor/attach/EditorAttachmentBlocks.java index 8af4cd84..d493532a 100644 --- a/app/src/main/java/com/pasich/mynotes/extendedEditor/attach/EditorAttachmentBlocks.java +++ b/app/src/main/java/com/pasich/mynotes/extendedEditor/attach/EditorAttachmentBlocks.java @@ -8,6 +8,7 @@ import com.google.gson.JsonParser; import java.util.ArrayList; import java.util.List; +import java.util.Set; /** * The one walk over an Editor.js document that knows where attachment references live. @@ -81,6 +82,89 @@ public static List fileUrls(@Nullable String valueJson) { return urls; } + /** A document with some attachment references taken out, and how many were. */ + public static final class Filtered { + @Nullable public final String valueJson; + public final int removed; + + Filtered(@Nullable String valueJson, int removed) { + this.valueJson = valueJson; + this.removed = removed; + } + } + + /** + * Takes out every file reference whose URL is not in {@code keep}. + * + *

A block whose only file goes is removed with it, since an image or attachment block + * without its file renders as a broken placeholder; a block listing several files keeps the + * ones that remain. + * + * @return the filtered document, with {@code valueJson} itself returned verbatim when nothing + * was taken out or the document could not be read. + */ + @NonNull + public static Filtered keepOnlyFiles(@Nullable String valueJson, @NonNull Set keep) { + if (valueJson == null || valueJson.trim().isEmpty()) { + return new Filtered(valueJson, 0); + } + JsonArray blocks; + try { + blocks = JsonParser.parseString(valueJson).getAsJsonArray(); + } catch (RuntimeException unreadable) { + return new Filtered(valueJson, 0); + } + int removed = 0; + JsonArray result = new JsonArray(); + for (JsonElement element : blocks) { + if (!element.isJsonObject()) { + result.add(element); + continue; + } + JsonObject block = element.getAsJsonObject(); + JsonElement dataElement = block.get("data"); + if (dataElement == null || !dataElement.isJsonObject()) { + result.add(block); + continue; + } + JsonObject data = dataElement.getAsJsonObject(); + boolean hadFile = false; + boolean keptFile = false; + JsonElement file = data.get("file"); + if (hasUrl(file)) { + hadFile = true; + if (keep.contains(file.getAsJsonObject().get("url").getAsString())) { + keptFile = true; + } else { + data.remove("file"); + removed++; + } + } + JsonElement list = data.get("files"); + if (list != null && list.isJsonArray()) { + JsonArray kept = new JsonArray(); + for (JsonElement candidate : list.getAsJsonArray()) { + if (!hasUrl(candidate)) { + kept.add(candidate); + continue; + } + hadFile = true; + if (keep.contains(candidate.getAsJsonObject().get("url").getAsString())) { + kept.add(candidate); + keptFile = true; + } else { + removed++; + } + } + data.add("files", kept); + } + if (hadFile && !keptFile) continue; + result.add(block); + } + // JsonElement.toString(), as in rewriteUrls, so the output is serialized the same way. + return removed == 0 ? new Filtered(valueJson, 0) : new Filtered(result.toString(), removed); + } + /** The file object of the block with {@code blockId}, or {@code null}. */ @Nullable public static JsonObject findFile(@Nullable String valueJson, @Nullable String blockId) { diff --git a/app/src/main/java/com/pasich/mynotes/extendedEditor/attach/RecentAttachmentUploads.java b/app/src/main/java/com/pasich/mynotes/extendedEditor/attach/RecentAttachmentUploads.java new file mode 100644 index 00000000..04351e39 --- /dev/null +++ b/app/src/main/java/com/pasich/mynotes/extendedEditor/attach/RecentAttachmentUploads.java @@ -0,0 +1,54 @@ +package com.pasich.mynotes.extendedEditor.attach; + +import androidx.annotation.NonNull; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; + +/** + * Files the editor stored in this process whose block may not have reached the saved note yet. + * + *

An upload writes its file first; the block that references it reaches the database only on the + * next save. A cleanup in between (the autosave of the previous change, a save on leaving the + * screen) saw a file nobody referenced and deleted the image the user had just inserted. The + * cleaner leaves every file listed here alone until a saved note references it. + */ +public final class RecentAttachmentUploads { + + private static final Map> UPLOADED = new HashMap<>(); + + private RecentAttachmentUploads() {} + + /** Records a file just written into {@code note_}. */ + public static synchronized void register(int noteId, @NonNull String fileName) { + Set names = UPLOADED.get(noteId); + if (names == null) { + names = new HashSet<>(); + UPLOADED.put(noteId, names); + } + names.add(fileName); + } + + /** The files of a note that must not be treated as orphans yet. */ + @NonNull + public static synchronized Set protectedNames(int noteId) { + Set names = UPLOADED.get(noteId); + return names == null ? Collections.emptySet() : new HashSet<>(names); + } + + /** Stops protecting files a saved note now references; the cleaner keeps those anyway. */ + public static synchronized void settle(int noteId, @NonNull Collection referenced) { + Set names = UPLOADED.get(noteId); + if (names == null) return; + names.removeAll(referenced); + if (names.isEmpty()) UPLOADED.remove(noteId); + } + + /** For tests. */ + static synchronized void clear() { + UPLOADED.clear(); + } +} diff --git a/app/src/main/java/com/pasich/mynotes/extendedEditor/models/PickedFile.java b/app/src/main/java/com/pasich/mynotes/extendedEditor/models/PickedFile.java new file mode 100644 index 00000000..ea12aa92 --- /dev/null +++ b/app/src/main/java/com/pasich/mynotes/extendedEditor/models/PickedFile.java @@ -0,0 +1,28 @@ +package com.pasich.mynotes.extendedEditor.models; + +import android.net.Uri; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +/** A file the system picker returned: its content URI plus the name and size it reported. */ +public final class PickedFile { + + @NonNull public final Uri uri; + @Nullable public final String name; + public final long size; + + public PickedFile(@NonNull Uri uri, @Nullable String name, long size) { + this.uri = uri; + this.name = name; + this.size = size; + } + + /** + * Whether this is the file the page is uploading. The page's File carries the display name and + * size the provider reported, so either one identifying it is enough. + */ + public boolean matches(@Nullable String pageName, long pageSize) { + if (name != null && !name.isEmpty() && name.equals(pageName)) return true; + return size > 0 && size == pageSize; + } +} diff --git a/app/src/main/java/com/pasich/mynotes/extendedEditor/utils/EditorJSInterface.java b/app/src/main/java/com/pasich/mynotes/extendedEditor/utils/EditorJSInterface.java index c7614a13..9752f0a6 100644 --- a/app/src/main/java/com/pasich/mynotes/extendedEditor/utils/EditorJSInterface.java +++ b/app/src/main/java/com/pasich/mynotes/extendedEditor/utils/EditorJSInterface.java @@ -1,22 +1,32 @@ package com.pasich.mynotes.extendedEditor.utils; -import static com.pasich.mynotes.extendedEditor.attach.AttachmentStorage.ATTACHMENTS_BASE_DIR; -import static com.pasich.mynotes.extendedEditor.utils.EditorJsScheme.EDITORJS_SCHEME; - import android.content.Context; -import android.net.Uri; +import android.graphics.BitmapFactory; +import android.os.Handler; +import android.os.Looper; import android.util.Base64; import android.util.Log; import android.webkit.JavascriptInterface; +import android.webkit.ValueCallback; import android.webkit.WebView; import android.widget.Toast; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.VisibleForTesting; import com.pasich.mynotes.R; import com.pasich.mynotes.data.model.Note; import com.pasich.mynotes.extendedEditor.attach.AttachmentStorage; +import com.pasich.mynotes.extendedEditor.attach.AttachmentUrl; +import com.pasich.mynotes.extendedEditor.attach.RecentAttachmentUploads; import com.pasich.mynotes.extendedEditor.models.EditorAttachment; +import com.pasich.mynotes.extendedEditor.models.PickedFile; import com.pasich.mynotes.extendedEditor.models.SettingsEditorJsBridge; import java.io.File; +import java.util.ArrayList; +import java.util.List; import java.util.Map; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; import org.json.JSONArray; import org.json.JSONObject; @@ -30,12 +40,31 @@ public class EditorJSInterface { public static final String nameInterface = "Android"; + + /** Upload kind for the image tool; anything else is stored as a plain attachment. */ + public static final String KIND_IMAGE = "image"; + + public static final String KIND_FILE = "file"; private static final String TAG = "EditorJSInterface"; + /** + * How long a finishing page may take to hand over its last edit before the WebView is destroyed + * anyway. + */ + @VisibleForTesting static final long FINISH_TIMEOUT_MS = 1500; + private final EditorListener listener; private final WebView webView; private final Context appContext; private final SettingsEditorJsBridge settings; + private final Handler mainHandler = new Handler(Looper.getMainLooper()); + // One file at a time, in the order they were picked; never on the JS or UI thread. + private final ExecutorService uploads = Executors.newSingleThreadExecutor(); + private final List pickedFiles = new ArrayList<>(); + // Set once the WebView is destroyed: nothing may be sent to it after that. + private volatile boolean released = false; + // Runs once the page has finished (onPageFinished) or the wait timed out. + @Nullable private volatile Runnable pendingFinish; public EditorJSInterface( EditorListener listener, @@ -70,6 +99,201 @@ public void onContentChanged(String jsonData) { if (listener != null) listener.onContentChanged(jsonData); } + /** + * Answer to {@link #requestFlush()}: the document as it is right now, sent without waiting for + * the editor's change batching. + */ + @SuppressWarnings("unused") + @JavascriptInterface + public void onContentFlushed(String jsonData) { + if (listener != null) listener.onContentFlushed(jsonData); + } + + /** + * Reports which block is at the top of the viewport once scrolling settles, so the reading + * position can be restored after a rotation. + * + * @param blockIndex index of the first visible block, or -1 while the title is on screen. + * @param offsetPx that block's distance from the top of the viewport, in CSS pixels. + */ + @SuppressWarnings("unused") + @JavascriptInterface + public void onViewportAnchor(int blockIndex, int offsetPx) { + if (listener != null) listener.onViewportAnchor(blockIndex, offsetPx); + } + + /** + * Reports where the note is being read or edited (the block at the top of the viewport and the + * caret's block and offset) once scrolling or the caret settles, so the position can be saved + * when the note is left. + * + * @param json the page's {@code currentViewState()}, read by {@link ExtendedViewStateJson}. + */ + @SuppressWarnings("unused") + @JavascriptInterface + public void onViewState(String json) { + if (listener != null) listener.onViewState(json); + } + + /** + * The page switched reading mode, from the toolbar or by a double tap on the text. + * + * @param readOnly whether reading mode is now on. + * @param byDoubleTap whether a double tap started editing at the tapped text. + */ + @SuppressWarnings("unused") + @JavascriptInterface + public void onReadModeChanged(boolean readOnly, boolean byDoubleTap) { + if (listener != null) listener.onReadModeChanged(readOnly, byDoubleTap); + } + + /** The note handed over by loadNoteToEditor has been rendered. */ + @SuppressWarnings("unused") + @JavascriptInterface + public void onNoteRendered() { + if (listener != null) listener.onNoteRendered(); + } + + /** + * The page's undo history changed: whether there is something to undo or redo. + * + * @param canUndo whether an undo would change the note. + * @param canRedo whether a redo would change the note. + */ + @SuppressWarnings("unused") + @JavascriptInterface + public void onHistoryChanged(boolean canUndo, boolean canRedo) { + if (listener != null) listener.onHistoryChanged(canUndo, canRedo); + } + + /** + * Attachment files an edit took out of the note that an undo may bring back. They are kept from + * the orphan cleanup the way a fresh upload is, until a saved note refers to them again or the + * app process ends. + * + * @param urlsJson a JSON array of attachment URLs. + */ + @SuppressWarnings("unused") + @JavascriptInterface + public void keepForUndo(String urlsJson) { + if (released || urlsJson == null || listener == null) return; + int noteId = listener.getNoteId(); + if (noteId <= 0) return; + try { + JSONArray urls = new JSONArray(urlsJson); + for (int i = 0; i < urls.length(); i++) { + AttachmentUrl url = AttachmentUrl.parse(urls.optString(i, null)); + if (url != null && url.getNoteFolder().equals("note_" + noteId)) { + RecentAttachmentUploads.register(noteId, url.getFileName()); + } + } + } catch (Exception e) { + Log.w(TAG, "keepForUndo: unreadable list", e); + } + } + + /** Takes back the last change in the page. */ + public void undo() { + evaluate("window.historyUndo && historyUndo();"); + } + + /** Applies the last undone change in the page again. */ + public void redo() { + evaluate("window.historyRedo && historyRedo();"); + } + + /** + * Runs a script in the page: at once on the main thread, otherwise posted to it. Never reaches + * a destroyed WebView, also when a script was posted before it was destroyed. + */ + private void evaluate(String script) { + if (webView == null || released) return; + if (Looper.myLooper() == Looper.getMainLooper()) { + webView.evaluateJavascript(script, null); + return; + } + mainHandler.post( + () -> { + if (!released) webView.evaluateJavascript(script, null); + }); + } + + /** + * Reads where the note is being read or edited right now; {@code callback} gets the page's JSON + * on the main thread, or null when the page has no position yet or is gone. + */ + public void readViewState(@NonNull ValueCallback callback) { + if (webView == null || released) { + callback.onReceiveValue(null); + return; + } + webView.evaluateJavascript( + "window.currentViewStateJson ? currentViewStateJson() : null", + result -> callback.onReceiveValue(ExtendedViewStateJson.fromScriptResult(result))); + } + + /** Makes sure the page holds a caret for the keyboard that is about to open. */ + public void focusCaret() { + evaluate("window.focusCaretFromAndroid && focusCaretFromAndroid();"); + } + + /** Asks the editor to send its document now; it answers through onContentFlushed. */ + public void requestFlush() { + evaluate("window.flushContent && flushContent();"); + } + + /** + * The screen is going away: asks the page for its last edit (and, with {@code keepHistory}, its + * undo history, through onHistoryExported) and runs {@code then} on the main thread once the + * page has answered, or after {@link #FINISH_TIMEOUT_MS}. {@code then} destroys the WebView; + * until it runs the page stays alive so the answer can still arrive. + */ + public void finishPage(boolean keepHistory, @NonNull Runnable then) { + if (webView == null || released) { + then.run(); + return; + } + Runnable once = + new Runnable() { + private boolean done; + + @Override + public void run() { + if (done) return; + done = true; + pendingFinish = null; + mainHandler.removeCallbacks(this); + then.run(); + } + }; + pendingFinish = once; + mainHandler.postDelayed(once, FINISH_TIMEOUT_MS); + evaluate( + "window.finishPage ? finishPage(" + + keepHistory + + ") : " + + nameInterface + + ".onPageFinished();"); + } + + /** Answer to {@link #finishPage}: the page has sent everything it had. */ + @SuppressWarnings("unused") + @JavascriptInterface + public void onPageFinished() { + Runnable finish = pendingFinish; + if (finish != null) mainHandler.post(finish); + } + + /** + * The page's undo history, handed over while the screen is recreated so the next page can take + * it over. + */ + @SuppressWarnings("unused") + @JavascriptInterface + public void onHistoryExported(String json) { + if (listener != null && json != null) listener.onHistoryExported(json); + } + /** * Called from JS whenever the title field inside the editor changes. Executed on the main * thread to safely update UI listeners. @@ -77,7 +301,10 @@ public void onContentChanged(String jsonData) { @SuppressWarnings("unused") @JavascriptInterface public void onTitleChanged(String title) { - webView.post(() -> listener.onTitleChanged(title)); + mainHandler.post( + () -> { + if (!released) listener.onTitleChanged(title); + }); } /** Receives error messages thrown by the JS editor and reports them to listener. */ @@ -98,9 +325,7 @@ public void setThemeColors(Map colors) { try { JSONObject json = new JSONObject(colors); - final String jsCommand = "setThemeColors(" + json + ");"; - - webView.post(() -> webView.evaluateJavascript(jsCommand, null)); + evaluate("setThemeColors(" + json + ");"); } catch (Exception e) { Log.e(TAG, "Failed to set theme colors: " + e.getMessage()); } @@ -113,6 +338,27 @@ public void setThemeColors(Map colors) { * @param note The note model which will be rendered in the editor. */ public void loadNoteToEditor(Note note) { + loadNoteToEditor(note, -1, 0, null, false, null); + } + + /** + * Loads a note and, once it is rendered, scrolls block {@code anchorIndex} to {@code + * anchorOffset} CSS pixels from the top. A negative index keeps the top of the note. + * + * @param viewState a saved position resolved for this note ({@link + * ExtendedViewStateJson#toPage}), applied instead of the anchor; null for none. + * @param focusStart put the caret at the start of the first block once rendered, unless a + * restored position places it. + * @param history the undo history the page this one replaces handed over ({@link + * #onHistoryExported}); the page takes it over only for the same document. Null for none. + */ + public void loadNoteToEditor( + Note note, + int anchorIndex, + int anchorOffset, + @Nullable String viewState, + boolean focusStart, + @Nullable String history) { if (webView == null || note == null) return; try { JSONObject json = new JSONObject(); @@ -134,82 +380,200 @@ public void loadNoteToEditor(Note note) { } json.put("plainTextFallback", isPlainTextFallback); - String jsCommand = "loadNote(JSON.parse(" + JSONObject.quote(json.toString()) + "));"; - webView.post(() -> webView.evaluateJavascript(jsCommand, null)); + if (anchorIndex >= 0) { + JSONObject anchor = new JSONObject(); + anchor.put("index", anchorIndex); + anchor.put("offset", anchorOffset); + json.put("anchor", anchor); + } + if (viewState != null) json.put("viewState", new JSONObject(viewState)); + if (focusStart) json.put("focusStart", true); + if (history != null) { + try { + json.put("history", new JSONObject(history)); + } catch (Exception e) { + Log.w(TAG, "Unreadable undo history, starting without it", e); + } + } + evaluate("loadNote(JSON.parse(" + JSONObject.quote(json.toString()) + "));"); } catch (Exception e) { Log.e(TAG, "Failed to load note: " + e.getMessage(), e); } } /** - * Receives a Base64-encoded file from Editor.js, decodes it, saves it to secure attachment - * storage and returns a file:// URL that Editor.js stores inside the block metadata. + * Files the system picker just returned, offered to the page's next upload request. The page + * only sees a File; Android keeps the content URI and reads it itself, so the bytes never cross + * the bridge. + */ + public void offerPickedFiles(List files) { + synchronized (pickedFiles) { + pickedFiles.clear(); + pickedFiles.addAll(files); + } + } + + /** + * Starts storing a file the picker returned, off the JS and UI threads. * - * @param base64 Encoded file content sent by JS. - * @param originalName Name of the file provided by the Editor.js tool. - * @return A file://attachments/... URL or empty string on error. + * @return true when the file was found and the upload started; the result then arrives through + * {@code window.__onUploadFinished(requestId, result)}. False when the page has to send the + * bytes itself ({@link #uploadBase64Async}). */ @SuppressWarnings("unused") @JavascriptInterface - public String uploadFile(String base64, String originalName) { - originalName = originalName.replace("'", "_").replace(" ", "_"); + public boolean requestPickedUpload(String requestId, String kind, String name, double size) { + PickedFile match = takePickedFile(name, (long) size); + if (match == null || released) return false; + uploads.execute( + () -> { + byte[] raw = AttachmentStorage.readUri(appContext, match.uri); + deliverUpload(requestId, raw == null ? null : store(raw, match.name, kind)); + }); + return true; + } - int noteId = listener.getNoteId(); + /** + * Stores bytes the page sent (a pasted or dropped file) in the background and answers through + * {@code window.__onUploadFinished(requestId, result)}. Returns at once, so the page is never + * blocked for the length of the save. + */ + @SuppressWarnings("unused") + @JavascriptInterface + public boolean uploadBase64Async(String requestId, String kind, String base64, String name) { + if (released || base64 == null) return false; + uploads.execute( + () -> { + String payload = base64; + int comma = payload.indexOf(','); + if (comma != -1) payload = payload.substring(comma + 1); + byte[] raw; + try { + raw = Base64.decode(payload, Base64.DEFAULT); + } catch (IllegalArgumentException e) { + raw = null; + } + deliverUpload(requestId, raw == null ? null : store(raw, name, kind)); + }); + return true; + } - // strip header - int idx = base64.indexOf(","); - if (idx != -1) base64 = base64.substring(idx + 1); + /** + * Stores a file the picker returned to a page that no longer exists (the screen was recreated + * while the picker was open) and inserts its block at {@code index}. + * + * @param onFailed run on the main thread when the file could not be stored. + */ + public void uploadAndInsert(String kind, PickedFile file, int index, Runnable onFailed) { + if (released) return; + uploads.execute( + () -> { + byte[] raw = AttachmentStorage.readUri(appContext, file.uri); + JSONObject result = raw == null ? null : store(raw, file.name, kind); + mainHandler.post( + () -> { + if (released || webView == null) return; + JSONObject stored = + result == null ? null : result.optJSONObject("file"); + if (stored == null) { + onFailed.run(); + return; + } + webView.evaluateJavascript( + "window.insertUploadedBlockFromAndroid && " + + "insertUploadedBlockFromAndroid(" + + JSONObject.quote(kind) + + "," + + stored + + "," + + index + + ");", + null); + }); + }); + } - byte[] raw = Base64.decode(base64, Base64.DEFAULT); - if (raw == null || raw.length == 0) return ""; + /** + * Stops accepting uploads and sending anything to the page; called when the WebView is + * destroyed. + */ + public void release() { + released = true; + Runnable finish = pendingFinish; + if (finish != null) mainHandler.removeCallbacks(finish); + pendingFinish = null; + uploads.shutdownNow(); + } - File saved = - AttachmentStorage.save( - appContext, noteId, originalName, raw, settings.isExtraOptimizeEnabled()); - if (saved == null) return ""; - return new Uri.Builder() - .scheme(EDITORJS_SCHEME) - .authority(ATTACHMENTS_BASE_DIR) - .appendPath("note_" + noteId) - .appendPath(saved.getName()) - .build() - .toString(); + private PickedFile takePickedFile(String name, long size) { + synchronized (pickedFiles) { + for (int i = 0; i < pickedFiles.size(); i++) { + PickedFile candidate = pickedFiles.get(i); + if (candidate.matches(name, size)) { + return pickedFiles.remove(i); + } + } + return null; + } } /** - * Accepts a base64 image from Editor.js, stores it in internal storage, and returns an - * Editor.js-compatible response in the following format: { “success”: 1, “file”: { ‘url’: - * “editorjs://attachments/...” } } - * - *

Returns on error: { “success”: 0 } - * - *

- uploadFile(...) performs the actual save and returns a URL or “” + * Saves raw bytes into the note's attachment folder and describes the stored file in the + * Editor.js upload response format. Runs on the upload thread. */ - @SuppressWarnings("unused") - @JavascriptInterface - public String uploadImage(String base64, String originalName) { - String fileUrl = uploadFile(base64, originalName); - + private JSONObject store(byte[] raw, String originalName, String kind) { + if (raw == null || raw.length == 0) return null; + String name = + originalName == null || originalName.isEmpty() + ? "file" + : originalName.replace("'", "_").replace(" ", "_"); + int noteId = listener.getNoteId(); + File saved = + AttachmentStorage.save( + appContext, noteId, name, raw, settings.isExtraOptimizeEnabled()); + if (saved == null) return null; + RecentAttachmentUploads.register(noteId, saved.getName()); try { - JSONObject root = new JSONObject(); - if (fileUrl.isEmpty()) { - root.put("success", 0); - return root.toString(); + JSONObject file = new JSONObject(); + file.put("url", AttachmentStorage.urlFor(noteId, saved.getName())); + if (KIND_IMAGE.equals(kind)) { + BitmapFactory.Options bounds = new BitmapFactory.Options(); + bounds.inJustDecodeBounds = true; + BitmapFactory.decodeFile(saved.getAbsolutePath(), bounds); + if (bounds.outWidth > 0 && bounds.outHeight > 0) { + file.put("width", bounds.outWidth); + file.put("height", bounds.outHeight); + } + } else { + int dot = name.lastIndexOf('.'); + file.put("name", name); + file.put("size", saved.length()); + file.put("extension", dot == -1 ? "" : name.substring(dot + 1)); } - - JSONObject fileObj = new JSONObject(); - fileObj.put("url", fileUrl); - + JSONObject root = new JSONObject(); root.put("success", 1); - root.put("file", fileObj); - - return root.toString(); - + root.put("file", file); + return root; } catch (Exception e) { - return "{\"success\":0}"; + Log.e(TAG, "Failed to describe the stored file", e); + return null; } } + private void deliverUpload(String requestId, JSONObject result) { + String script = + "window.__onUploadFinished && __onUploadFinished(" + + JSONObject.quote(requestId) + + "," + + (result == null ? "null" : result.toString()) + + ");"; + mainHandler.post( + () -> { + if (released || webView == null) return; + webView.evaluateJavascript(script, null); + }); + } + /** * Called when JS requests to open an attachment preview. Parses attachment JSON and forwards it * to the listener (Activity). @@ -251,7 +615,7 @@ public void onAttachmentBlockDeletedResponse(String blockId, String fileUrl) { /** Toggles read-only mode inside Editor.js (title and blocks become non-editable). */ public void toggleReadMode() { - webView.post(() -> webView.evaluateJavascript("toggleReadModeFromAndroid();", null)); + evaluate("toggleReadModeFromAndroid();"); } /** @@ -265,8 +629,7 @@ public void deleteAttachmentBlockRequest(String blockId, String fileUrl) { // If fileUrl == null → JS should receive “null” instead of “null string” String jsUrl = (fileUrl == null) ? "null" : "'" + fileUrl.replace("'", "\\'") + "'"; - webView.evaluateJavascript( - "deleteAttachmentBlockFromAndroid('" + blockId + "', " + jsUrl + ");", null); + evaluate("deleteAttachmentBlockFromAndroid('" + blockId + "', " + jsUrl + ");"); } /** @@ -287,8 +650,23 @@ public interface EditorListener { void onContentChanged(String jsonData); + void onContentFlushed(String jsonData); + + void onViewportAnchor(int blockIndex, int offsetPx); + + void onViewState(String json); + + void onReadModeChanged(boolean readOnly, boolean byDoubleTap); + + void onHistoryChanged(boolean canUndo, boolean canRedo); + + void onNoteRendered(); + void onTitleChanged(String tile); + /** The page's undo history, handed over while the screen is recreated. */ + default void onHistoryExported(String json) {} + void onError(String error); void openFile(EditorAttachment attachment); diff --git a/app/src/main/java/com/pasich/mynotes/extendedEditor/utils/ExtendedViewStateJson.java b/app/src/main/java/com/pasich/mynotes/extendedEditor/utils/ExtendedViewStateJson.java new file mode 100644 index 00000000..1bee5004 --- /dev/null +++ b/app/src/main/java/com/pasich/mynotes/extendedEditor/utils/ExtendedViewStateJson.java @@ -0,0 +1,141 @@ +package com.pasich.mynotes.extendedEditor.utils; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParseException; +import com.google.gson.JsonParser; +import com.pasich.mynotes.utils.editor.NoteViewState; +import com.pasich.mynotes.utils.editor.PositionRestorer; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** + * Translates the extended editor's position between the page's JSON and {@link NoteViewState}. Uses + * Gson rather than org.json so it runs in plain JVM tests. + */ +public final class ExtendedViewStateJson { + + private ExtendedViewStateJson() {} + + /** + * Reads the position the page reported ({@code currentViewState()} in runtime.js), or null when + * it cannot be read. + */ + @Nullable + public static NoteViewState.Extended fromPage(@Nullable String json) { + JsonObject page = object(json); + if (page == null) return null; + NoteViewState.Extended state = new NoteViewState.Extended(); + state.scrollTop = Math.max(0, intOf(page, "scrollTop", 0)); + state.scrollRatio = Math.max(0f, Math.min(1f, floatOf(page, "ratio"))); + state.topBlockId = stringOf(page, "topId"); + state.topBlockIndex = intOf(page, "topIndex", -1); + state.topOffsetPx = intOf(page, "topOffset", 0); + JsonElement caret = page.get("caret"); + if (caret != null && caret.isJsonObject()) { + JsonObject c = caret.getAsJsonObject(); + state.caretBlockId = stringOf(c, "id"); + state.caretInput = Math.max(0, intOf(c, "input", 0)); + state.caretOffset = Math.max(0, intOf(c, "offset", 0)); + } + return state; + } + + /** + * The page's position from the result of {@code currentViewStateJson()} run through {@code + * WebView.evaluateJavascript}, which hands back the returned string JSON-encoded; null when the + * page had no position to give. + */ + @Nullable + public static String fromScriptResult(@Nullable String result) { + if (result == null) return null; + try { + JsonElement value = JsonParser.parseString(result); + if (!value.isJsonPrimitive() || !value.getAsJsonPrimitive().isString()) return null; + String json = value.getAsString(); + return json.isEmpty() ? null : json; + } catch (JsonParseException | IllegalStateException e) { + return null; + } + } + + /** The target in the form {@code restoreViewState()} in runtime.js takes. */ + @NonNull + public static JsonObject toPage(@NonNull PositionRestorer.ExtendedTarget target) { + JsonObject page = new JsonObject(); + if (target.caretBlockId != null) { + page.addProperty("caretId", target.caretBlockId); + page.addProperty("caretInput", target.caretInput); + page.addProperty("caretOffset", target.caretOffset); + } + if (target.topBlockId != null) page.addProperty("topId", target.topBlockId); + page.addProperty("topIndex", target.topBlockIndex); + page.addProperty("topOffset", target.topOffsetPx); + return page; + } + + /** Ids of the blocks in an Editor.js document, in order; empty when there are none. */ + @NonNull + public static List blockIds(@Nullable String valueJson) { + if (valueJson == null || valueJson.trim().isEmpty()) return Collections.emptyList(); + JsonArray blocks; + try { + JsonElement root = JsonParser.parseString(valueJson); + if (root.isJsonObject() && root.getAsJsonObject().has("blocks")) { + root = root.getAsJsonObject().get("blocks"); + } + if (!root.isJsonArray()) return Collections.emptyList(); + blocks = root.getAsJsonArray(); + } catch (JsonParseException | IllegalStateException e) { + return Collections.emptyList(); + } + List ids = new ArrayList<>(blocks.size()); + for (JsonElement block : blocks) { + String id = block.isJsonObject() ? stringOf(block.getAsJsonObject(), "id") : null; + // A block without an id gets a new one on every render: it can only be matched by + // index, so it keeps its place in the list without a usable id. + ids.add(id != null ? id : ""); + } + return ids; + } + + @Nullable + private static JsonObject object(@Nullable String json) { + if (json == null || json.isEmpty()) return null; + try { + JsonElement root = JsonParser.parseString(json); + return root.isJsonObject() ? root.getAsJsonObject() : null; + } catch (JsonParseException | IllegalStateException e) { + return null; + } + } + + @Nullable + private static String stringOf(JsonObject object, String name) { + JsonElement value = object.get(name); + if (value == null || !value.isJsonPrimitive()) return null; + String text = value.getAsString(); + return text.isEmpty() ? null : text; + } + + private static int intOf(JsonObject object, String name, int fallback) { + JsonElement value = object.get(name); + if (value == null || !value.isJsonPrimitive() || !value.getAsJsonPrimitive().isNumber()) { + return fallback; + } + return (int) Math.round(value.getAsDouble()); + } + + private static float floatOf(JsonObject object, String name) { + JsonElement value = object.get(name); + if (value == null || !value.isJsonPrimitive() || !value.getAsJsonPrimitive().isNumber()) { + return 0f; + } + float result = (float) value.getAsDouble(); + return Float.isNaN(result) ? 0f : result; + } +} diff --git a/app/src/main/java/com/pasich/mynotes/ui/contract/MainContract.java b/app/src/main/java/com/pasich/mynotes/ui/contract/MainContract.java index 53295c3c..f9721465 100644 --- a/app/src/main/java/com/pasich/mynotes/ui/contract/MainContract.java +++ b/app/src/main/java/com/pasich/mynotes/ui/contract/MainContract.java @@ -8,6 +8,7 @@ import com.pasich.mynotes.data.model.Tag; import com.pasich.mynotes.ui.state.MainViewState; import com.pasich.mynotes.ui.state.StatsData; +import com.pasich.mynotes.utils.search.SearchHit; import dagger.hilt.android.scopes.ActivityScoped; import java.util.ArrayList; import java.util.List; @@ -35,8 +36,11 @@ interface view extends BaseView, MoreNoteMainActivityView { /** Shows the confirmation dialog to delete a tag. */ void startDeleteTagDialog(Tag tag); - /** Renders search results to the UI. */ - void renderSearch(List filtered); + /** + * Renders ranked search results. {@code titlesOnly} is true while the query is too short to + * search note text, so an empty result asks the user to keep typing. + */ + void renderSearch(List hits, boolean titlesOnly); /** Updates the drawer statistics panel. */ void renderDrawerStats(StatsData stats); @@ -53,6 +57,15 @@ interface presenter extends BasePresenter { /** Handles the new-note FAB click. */ void newNotesClick(); + /** Whether the list is in the custom order, where notes can be dragged. */ + boolean isCustomOrder(); + + /** + * Moves a note in the custom order to between two neighbours, null at either end. Not an + * edit: nothing else about the note changes. + */ + void moveNoteInCustomOrder(int noteId, Integer upperId, Integer lowerId); + /** Moves an array of notes to trash. */ void deleteNotesArray(ArrayList notes); diff --git a/app/src/main/java/com/pasich/mynotes/ui/contract/NoteContract.java b/app/src/main/java/com/pasich/mynotes/ui/contract/NoteContract.java index d03e8ae4..a4201b26 100644 --- a/app/src/main/java/com/pasich/mynotes/ui/contract/NoteContract.java +++ b/app/src/main/java/com/pasich/mynotes/ui/contract/NoteContract.java @@ -42,6 +42,13 @@ interface view extends BaseView, ActionBar, MoreNoteNoteActivityView { /** Reloads the extended editor with the current note content. */ void reloadExtendedEditor(); + + /** + * Starts the editor's undo history again: a note was loaded, or replaced by a copy or a + * restored version, so nothing typed before may be undone into it. Saving never calls this, + * so the history survives every autosave. + */ + void resetEditHistory(); } interface presenter extends BasePresenter { @@ -98,6 +105,21 @@ interface presenter extends BasePresenter { /** Requests duplication of the current note. */ void copyNoteRequest(); + + /** + * Saves right away whatever the debounced autosave is still holding. Called when the editor + * stops, so leaving the screen never loses the last edits. + */ + void flushPending(); + + /** Returns true if the note holds content that has not been written yet. */ + boolean hasUnsavedChanges(); + + /** + * Reads the note again from the database and shows it, after something other than this + * editor changed it — a version restored from its history. + */ + void reloadNote(); } interface AutoSaveCallback { diff --git a/app/src/main/java/com/pasich/mynotes/ui/controllers/SelectionController.java b/app/src/main/java/com/pasich/mynotes/ui/controllers/SelectionController.java index f0dbb90b..c7bf71ec 100644 --- a/app/src/main/java/com/pasich/mynotes/ui/controllers/SelectionController.java +++ b/app/src/main/java/com/pasich/mynotes/ui/controllers/SelectionController.java @@ -7,6 +7,7 @@ import com.pasich.mynotes.utils.adapters.notes.NoteAdapter; import com.pasich.mynotes.utils.recycler.payloads.NotePayloads; import java.util.ArrayList; +import java.util.Collection; import java.util.HashSet; import java.util.List; @@ -60,12 +61,15 @@ public boolean isInSelectionMode() { public void setPanelMode(Mode mode) { switch (mode) { case NORMAL: + panel.actionTagChange.setVisibility(View.VISIBLE); panel.actionShare.setVisibility(View.VISIBLE); panel.actionDelete.setVisibility(View.VISIBLE); panel.actionRestore.setVisibility(View.GONE); break; case RESTORE: + // A note in the trash has no tag to change. + panel.actionTagChange.setVisibility(View.GONE); panel.actionShare.setVisibility(View.GONE); panel.actionDelete.setVisibility(View.GONE); panel.actionRestore.setVisibility(View.VISIBLE); @@ -124,6 +128,35 @@ public void clearSelection() { notifyListener(); } + /** + * Drops selected notes that no longer exist (for example deleted by a sync). Leaves selection + * mode when nothing selected is left. + */ + public void retainOnly(Collection existingIds) { + if (!selectionMode) return; + HashSet keep = new HashSet<>(existingIds); + List gone = new ArrayList<>(); + for (int id : selectedIds) { + if (!keep.contains(id)) gone.add(id); + } + if (gone.isEmpty()) return; + + selectedIds.removeAll(gone); + for (int id : gone) { + updateNoteVisualState(id); + } + if (selectedIds.isEmpty()) { + clearSelection(); + return; + } + int count = selectedIds.size(); + panel.selectedCount.setText( + panel.getRoot() + .getResources() + .getQuantityString(R.plurals.selected_count, count, count)); + notifyListener(); + } + /** Returns the full Note objects whose IDs are currently selected. */ public List getSelectedNotes() { List result = new ArrayList<>(); diff --git a/app/src/main/java/com/pasich/mynotes/ui/controllers/mainActivity/AppUpdateController.java b/app/src/main/java/com/pasich/mynotes/ui/controllers/mainActivity/AppUpdateController.java index 625c64b6..1e31c0ac 100644 --- a/app/src/main/java/com/pasich/mynotes/ui/controllers/mainActivity/AppUpdateController.java +++ b/app/src/main/java/com/pasich/mynotes/ui/controllers/mainActivity/AppUpdateController.java @@ -4,7 +4,10 @@ import android.content.Intent; import androidx.activity.result.ActivityResultLauncher; import androidx.appcompat.app.AppCompatActivity; +import androidx.fragment.app.FragmentManager; import com.pasich.mynotes.ui.view.activity.ChangelogActivity; +import com.pasich.mynotes.ui.view.activity.MeetEnclyActivity; +import com.pasich.mynotes.ui.view.dialogs.MeetEnclyDialog; import com.pasich.mynotes.ui.view.dialogs.UpdateChangelogDialog; import com.pasich.mynotes.utils.UpdateChecker; @@ -14,6 +17,9 @@ */ public class AppUpdateController { + private static final String CHANGELOG_TAG = "UpdateChangelogDialog"; + private static final String MEET_ENCLY_TAG = "MeetEnclyDialog"; + private final Activity activity; private final UpdateChecker updateChecker; private final ActivityResultLauncher changelogLauncher; @@ -28,16 +34,34 @@ public AppUpdateController( updateChecker.initializeVersionCheck(); } - /** Shows the changelog dialog if a new app version was detected. */ + /** + * Shows the changelog dialog if a new app version was detected. On the first start of the + * version that introduces Encly, the one-time "Meet Encly" dialog takes its place; the + * changelog stays unread and follows on the next start. + */ public void showChangelogIfNeeded() { + FragmentManager fragmentManager = + ((AppCompatActivity) activity).getSupportFragmentManager(); + // A recreated activity gets its open dialog back from the fragment manager. + if (fragmentManager.findFragmentByTag(MEET_ENCLY_TAG) != null + || fragmentManager.findFragmentByTag(CHANGELOG_TAG) != null) { + return; + } + if (updateChecker.shouldShowMeetEncly()) { + updateChecker.markMeetEnclyShown(); + MeetEnclyDialog.newInstance().show(fragmentManager, MEET_ENCLY_TAG); + return; + } if (updateChecker.hasNewVersion()) { - UpdateChangelogDialog.newInstance() - .show( - ((AppCompatActivity) activity).getSupportFragmentManager(), - "UpdateChangelogDialog"); + UpdateChangelogDialog.newInstance().show(fragmentManager, CHANGELOG_TAG); } } + /** Opens the "Meet Encly" page. */ + public void openMeetEncly() { + activity.startActivity(new Intent(activity, MeetEnclyActivity.class)); + } + /** Returns whether a newer app version is available. */ public boolean hasNewVersion() { return updateChecker.hasNewVersion(); diff --git a/app/src/main/java/com/pasich/mynotes/ui/controllers/mainActivity/MainRenderListsController.java b/app/src/main/java/com/pasich/mynotes/ui/controllers/mainActivity/MainRenderListsController.java index 5beaf714..48b23117 100644 --- a/app/src/main/java/com/pasich/mynotes/ui/controllers/mainActivity/MainRenderListsController.java +++ b/app/src/main/java/com/pasich/mynotes/ui/controllers/mainActivity/MainRenderListsController.java @@ -1,184 +1,233 @@ package com.pasich.mynotes.ui.controllers.mainActivity; +import android.animation.ValueAnimator; import android.content.res.Resources; import android.view.View; +import android.view.ViewTreeObserver; import android.view.animation.AccelerateInterpolator; import android.view.animation.DecelerateInterpolator; -import android.view.animation.OvershootInterpolator; +import android.widget.TextView; +import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import androidx.annotation.VisibleForTesting; +import androidx.interpolator.view.animation.FastOutSlowInInterpolator; +import androidx.recyclerview.widget.RecyclerView; import androidx.recyclerview.widget.StaggeredGridLayoutManager; +import com.google.android.material.appbar.AppBarLayout; import com.pasich.mynotes.R; import com.pasich.mynotes.data.model.Tag; import com.pasich.mynotes.databinding.ActivityMainBinding; import com.pasich.mynotes.utils.managers.SystemTagsManager; import java.util.List; +import java.util.function.BooleanSupplier; -/** Controls note list and empty-state rendering with animations. */ +/** + * Shows the notes list, the empty state and the tags row. + * + *

Each of the three views is driven toward a target state ({@link ViewFader}), so overlapping + * requests — a sync that empties a category and fills it again a moment later, or a tag switch + * while the list is still fading — always settle on the latest one instead of leaving the screen + * blank. A full content swap (another category, another sort order, a reordered grid) fades the + * list out, replaces its content while nothing is visible, and fades it back in after the new + * layout is ready, so cards never slide across each other. + */ public class MainRenderListsController { - private final ActivityMainBinding binding; + private static final long LIST_SHOW_MS = 220; + private static final long LIST_HIDE_MS = 160; + private static final long LIST_DIM_MS = 110; + private static final long EMPTY_SHOW_MS = 200; + private static final long EMPTY_HIDE_MS = 150; + private static final long TAGS_SHOW_MS = 240; + private static final long TAGS_HIDE_MS = 180; + + private final RecyclerView listNotes; + private final TextView emptyText; + private final View emptyImage; private final Resources res; + private final BooleanSupplier animationsEnabled; - public MainRenderListsController(ActivityMainBinding binding) { - this.binding = binding; - this.res = binding.getRoot().getResources(); - } - - /** Briefly fades and rescales the list to signal a dataset change. */ - public void animateNoteListChange() { - binding.listNotes - .animate() - .alpha(0f) - .scaleY(0.97f) - .setDuration(120) - .withEndAction( - () -> { - binding.listNotes.scheduleLayoutAnimation(); - binding.listNotes - .animate() - .alpha(1f) - .scaleY(1f) - .setInterpolator(new OvershootInterpolator(0.6f)) - .setDuration(220) - .start(); - }) - .start(); - } + private final ViewFader listFader; + private final ViewFader emptyFader; + private final ViewFader tagsFader; - /** - * Handles switching between the notes list and the empty state view, including fade/scale - * animations and optional smooth scrolling. - */ - public void showStateNoteList(@Nullable Tag selectedTag, int mNotesCount) { - if (mNotesCount == 0) { - animateHideList(binding.listNotes); - animateShowEmpty(binding.includeEmpty.emptyViewNote); - updateEmptyText(new EmptyStateBuilder().build(selectedTag, mNotesCount)); - return; - } + /** True from the start of a swap until the swapped-in content has been laid out. */ + private boolean swapping; - animateHideEmpty(binding.includeEmpty.emptyViewNote); - animateShowList(binding.listNotes); - } + @Nullable private ViewTreeObserver.OnPreDrawListener pendingReveal; + @Nullable private ViewTreeObserver.OnPreDrawListener pendingJump; - /** Smoothly scrolls the notes list to the top. */ - public void scrollUpNoteList() { - binding.listNotes.post(() -> binding.listNotes.smoothScrollToPosition(0)); - } + /** The search bar and tags above the list; scrolled away with it, shown again at the top. */ + @Nullable private AppBarLayout appBar; - /** Instantly jumps the staggered-grid list to position zero. */ - public void scrollToTopInstant() { - binding.listNotes.post( - () -> { - StaggeredGridLayoutManager lm = - (StaggeredGridLayoutManager) binding.listNotes.getLayoutManager(); - if (lm != null) lm.scrollToPositionWithOffset(0, 0); - }); + public MainRenderListsController(ActivityMainBinding binding) { + this( + binding.listNotes, + binding.includeEmpty.emptyViewNote, + binding.includeEmpty.emptyNotesText, + binding.includeEmpty.imageEmpty, + binding.listTags, + ValueAnimator::areAnimatorsEnabled); + this.appBar = binding.appBarMainActivity; } - /** Smoothly shows the notes list using fade + scale animation. */ - private void animateShowList(View list) { - if (list.getVisibility() == View.VISIBLE) return; - - list.setVisibility(View.VISIBLE); - list.setAlpha(0f); - list.setScaleY(0.95f); - - list.animate() - .alpha(1f) - .scaleY(1f) - .setDuration(250) - .setInterpolator(new DecelerateInterpolator(1.6f)) - .start(); + @VisibleForTesting + public MainRenderListsController( + RecyclerView listNotes, + View emptyView, + TextView emptyText, + View emptyImage, + View listTags, + BooleanSupplier animationsEnabled) { + this.listNotes = listNotes; + this.emptyText = emptyText; + this.emptyImage = emptyImage; + this.res = listNotes.getResources(); + this.animationsEnabled = animationsEnabled; + this.listFader = new ViewFader(listNotes, View.INVISIBLE, 0.97f, animationsEnabled); + this.emptyFader = new ViewFader(emptyView, View.GONE, 1f, animationsEnabled); + listTags.setPivotY(0f); + this.tagsFader = new ViewFader(listTags, View.GONE, 0.85f, animationsEnabled); } - /** Smoothly hides the notes list with fade + scale collapse animation. */ - private void animateHideList(View list) { - if (list.getVisibility() != View.VISIBLE) return; - - list.animate() - .alpha(0f) - .scaleY(0.95f) - .setDuration(180) - .setInterpolator(new AccelerateInterpolator(1.4f)) - .withEndAction(() -> list.setVisibility(View.INVISIBLE)) - .start(); + /** Whether the system "remove animations" setting currently allows animation. */ + public boolean animationsEnabled() { + return animationsEnabled.getAsBoolean(); } - /** Shows the empty state view using a simple fade-in animation. */ - private void animateShowEmpty(View empty) { - if (empty.getVisibility() == View.VISIBLE) return; - - empty.setVisibility(View.VISIBLE); - empty.setAlpha(0f); - - empty.animate().alpha(1f).setDuration(200).start(); + /** + * True while the list content is being replaced behind a fade. Item animations must not run in + * that window: the new layout is meant to appear in one piece. + */ + public boolean isSwapping() { + return swapping; } - /** Hides the empty state view using fade-out animation. */ - private void animateHideEmpty(View empty) { - if (empty.getVisibility() != View.VISIBLE) return; + /** + * Shows the list when there are notes, the empty state otherwise. Safe to call any number of + * times, in any order: the last call wins. + * + * @param animate false to apply the result at once (for example while the screen is not + * visible) + */ + public void showStateNoteList(@Nullable Tag selectedTag, int notesCount, boolean animate) { + if (notesCount == 0) { + cancelReveal(); + swapping = false; + listFader.hide(animate, LIST_HIDE_MS, new AccelerateInterpolator(1.4f)); + updateEmptyText(new EmptyStateBuilder().build(selectedTag, notesCount)); + emptyFader.show(animate, EMPTY_SHOW_MS, new DecelerateInterpolator()); + return; + } - empty.animate() - .alpha(0f) - .setDuration(180) - .withEndAction(() -> empty.setVisibility(View.GONE)) - .start(); + emptyFader.hide(animate, EMPTY_HIDE_MS, new AccelerateInterpolator()); + if (swapping && animate) { + revealAfterLayout(); + } else { + cancelReveal(); + swapping = false; + listFader.show(animate, LIST_SHOW_MS, new DecelerateInterpolator(1.6f)); + } } /** - * Updates the empty state text based on the selected tag and note count. Also applies - * density-specific adjustments for low DPI devices. + * Replaces the whole list content behind a short fade: {@code submit} runs once the list is no + * longer visible, and must end by calling {@link #showStateNoteList} from its commit callback. + * Runs {@code submit} immediately when there is nothing on screen to fade. */ - private void updateEmptyText(String emptyText) { - binding.includeEmpty.emptyNotesText.setText(emptyText); + public void swapListContent(boolean animate, @NonNull Runnable submit) { + if (!animate || !animationsEnabled() || !listFader.isTargetShown()) { + submit.run(); + return; + } + swapping = true; + cancelReveal(); + RecyclerView.ItemAnimator itemAnimator = listNotes.getItemAnimator(); + if (itemAnimator != null) itemAnimator.endAnimations(); + listFader.dim(true, LIST_DIM_MS, new AccelerateInterpolator(), submit); + } - // Low-density devices optimization - if (binding.getRoot().getResources().getDisplayMetrics().density < 2.2) { - binding.includeEmpty.imageEmpty.setVisibility(View.GONE); + /** + * Readies the list for content just swapped in behind the fade: rebuilds the grid columns from + * scratch so the grid comes back without gaps and, when {@code toTop}, puts the first note at + * the top. + * + *

Does nothing for an empty list. Resetting the spans of a {@link + * StaggeredGridLayoutManager} and asking it to scroll while it has no items leaves it anchored + * wrong, and the next non-empty content was laid out below the screen: switching from an empty + * tag back to all notes showed a blank list until the app was restarted. + */ + public void prepareSwappedList(int notesCount, boolean toTop) { + if (notesCount == 0) return; + if (listNotes.getLayoutManager() instanceof StaggeredGridLayoutManager grid) { + grid.invalidateSpanAssignments(); } + if (toTop) jumpToTop(); } - /** Expands a horizontal list (tags list) using soft scale + fade animation. */ - private void expandHorizontal(View view) { - if (view.getVisibility() == View.VISIBLE) return; + /** Whether the first note is at the top of the list, or is about to be put there. */ + public boolean isAtTop() { + return pendingJump != null || !listNotes.canScrollVertically(-1); + } - view.setVisibility(View.VISIBLE); - view.setPivotY(0); - view.setScaleY(0.85f); - view.setAlpha(0f); + /** + * Puts the first note at the top, fully visible and without animation, together with the search + * bar and tags above it. + * + *

When the list still has adapter changes that were not laid out (the usual case: this is + * called from the commit callback of a new list) the scroll waits until they are. A {@link + * StaggeredGridLayoutManager} resolves a pending scroll against the children of the previous + * layout: if that layout's first child was the removed top note its position is -1 and nothing + * was laid out at all (an empty "All notes" after moving the top note to the trash); if the new + * top note was moved up from a visible card it measured the offset from where that card used to + * be, and the list stayed scrolled down. Once the changes are laid out the scroll is exact. The + * frame in between is not drawn, so the list never shows the intermediate position. + */ + public void jumpToTop() { + if (appBar != null) appBar.setExpanded(true, false); + if (!listNotes.hasPendingAdapterUpdates()) { + cancelPendingJump(); + scrollToTopNow(); + return; + } + if (pendingJump != null) return; + ViewTreeObserver.OnPreDrawListener listener = + new ViewTreeObserver.OnPreDrawListener() { + @Override + public boolean onPreDraw() { + // Not laid out yet (the window was not traversed): keep waiting. + if (listNotes.hasPendingAdapterUpdates()) return true; + removePendingJump(this); + scrollToTopNow(); + // Skip this frame; the next one is laid out at the top. + return false; + } + }; + pendingJump = listener; + listNotes.getViewTreeObserver().addOnPreDrawListener(listener); + } - view.animate() - .scaleY(1f) - .alpha(1f) - .setDuration(260) - .setInterpolator(new OvershootInterpolator(0.8f)) - .start(); + private void scrollToTopNow() { + RecyclerView.LayoutManager lm = listNotes.getLayoutManager(); + if (lm instanceof StaggeredGridLayoutManager grid) { + grid.scrollToPositionWithOffset(0, 0); + } else if (lm != null) { + lm.scrollToPosition(0); + } } - /** Collapses a horizontal list with fade + slight scale-down animation. */ - private void collapseHorizontal(View view) { - if (view.getVisibility() == View.GONE) return; + private void cancelPendingJump() { + if (pendingJump != null) removePendingJump(pendingJump); + } - view.animate() - .scaleY(0.85f) - .alpha(0f) - .setDuration(200) - .setInterpolator(new DecelerateInterpolator(1.6f)) - .withEndAction( - () -> { - view.setVisibility(View.GONE); - view.setScaleY(1f); - view.setAlpha(1f); - }) - .start(); + private void removePendingJump(ViewTreeObserver.OnPreDrawListener listener) { + ViewTreeObserver observer = listNotes.getViewTreeObserver(); + if (observer.isAlive()) observer.removeOnPreDrawListener(listener); + if (pendingJump == listener) pendingJump = null; } - /** - * Handles rendering of the tags list with animations depending on whether user-defined tags - * exist. - */ - public void renderListTags(List tags) { + /** Shows the tags row only when the user has tags of their own. */ + public void renderListTags(List tags, boolean animate) { boolean hasUserTags = false; for (Tag tag : tags) { if (!SystemTagsManager.isSystemTag(tag)) { @@ -188,9 +237,63 @@ public void renderListTags(List tags) { } if (hasUserTags) { - expandHorizontal(binding.listTags); + tagsFader.show(animate, TAGS_SHOW_MS, new FastOutSlowInInterpolator()); } else { - collapseHorizontal(binding.listTags); + tagsFader.hide(animate, TAGS_HIDE_MS, new DecelerateInterpolator(1.6f)); + } + } + + /** Cancels running animations and pending callbacks; call when the screen goes away. */ + public void release() { + cancelReveal(); + cancelPendingJump(); + listFader.cancel(); + emptyFader.cancel(); + tagsFader.cancel(); + } + + /** + * Fades the swapped-in list back in right before its first frame is drawn, so the new layout is + * complete (and item animations were skipped) by the time it becomes visible. + */ + private void revealAfterLayout() { + if (pendingReveal != null) return; + ViewTreeObserver.OnPreDrawListener listener = + new ViewTreeObserver.OnPreDrawListener() { + @Override + public boolean onPreDraw() { + removeReveal(this); + swapping = false; + if (animationsEnabled()) listNotes.scheduleLayoutAnimation(); + listFader.show(true, LIST_SHOW_MS, new DecelerateInterpolator(1.6f)); + return true; + } + }; + pendingReveal = listener; + listNotes.getViewTreeObserver().addOnPreDrawListener(listener); + listNotes.invalidate(); + } + + private void cancelReveal() { + if (pendingReveal != null) removeReveal(pendingReveal); + } + + private void removeReveal(ViewTreeObserver.OnPreDrawListener listener) { + ViewTreeObserver observer = listNotes.getViewTreeObserver(); + if (observer.isAlive()) observer.removeOnPreDrawListener(listener); + if (pendingReveal == listener) pendingReveal = null; + } + + /** + * Updates the empty state text based on the selected tag and note count. Also applies + * density-specific adjustments for low DPI devices. + */ + private void updateEmptyText(String text) { + emptyText.setText(text); + + // Low-density devices optimization + if (res.getDisplayMetrics().density < 2.2) { + emptyImage.setVisibility(View.GONE); } } @@ -199,7 +302,7 @@ private class EmptyStateBuilder { String build(@Nullable Tag selectedTag, int notesCount) { if (notesCount > 0) return ""; - if (selectedTag == null || "allNotes".equals(selectedTag.getNameTag())) { + if (selectedTag == null || SystemTagsManager.isAllNotesTag(selectedTag)) { return res.getString(R.string.emptyNotes); } diff --git a/app/src/main/java/com/pasich/mynotes/ui/controllers/mainActivity/NavigationController.java b/app/src/main/java/com/pasich/mynotes/ui/controllers/mainActivity/NavigationController.java index 77e7b9f7..dc3697e8 100644 --- a/app/src/main/java/com/pasich/mynotes/ui/controllers/mainActivity/NavigationController.java +++ b/app/src/main/java/com/pasich/mynotes/ui/controllers/mainActivity/NavigationController.java @@ -8,6 +8,7 @@ import androidx.activity.OnBackPressedCallback; import androidx.activity.result.ActivityResultLauncher; import androidx.annotation.NonNull; +import androidx.annotation.VisibleForTesting; import androidx.appcompat.app.AppCompatActivity; import androidx.core.graphics.Insets; import androidx.core.view.GravityCompat; @@ -38,6 +39,9 @@ public class NavigationController { private int swipeClose = 0; + /** A screen was opened from the drawer: the drawer closes once this one is out of sight. */ + private boolean closeWhenHidden; + public NavigationController( AppCompatActivity activity, ActivityMainBinding binding, @@ -170,14 +174,8 @@ private void setupHeaderButtons() { new Intent( activity, SupportActivity.class)))); - header.findViewById(R.id.drawerStatsCard) - .setOnClickListener( - v -> - delay( - () -> - activity.startActivity( - new Intent( - activity, SupportActivity.class)))); + header.findViewById(R.id.nav_meet_encly) + .setOnClickListener(v -> delay(appUpdateController::openMeetEncly)); bindHeaderNewVersion(header); } @@ -211,7 +209,25 @@ public void updateNewVersionIndicator(boolean visible) { } private void delay(Runnable r) { - new Handler(Looper.getMainLooper()).postDelayed(r, 100); + navigateAway(r); + } + + /** + * Opens another screen from the drawer. The drawer is closed when this screen stops (see {@link + * #onHostStopped}), so it is not open any more on the way back, and it is not seen sliding shut + * as the other screen opens. + */ + @VisibleForTesting + void navigateAway(Runnable open) { + closeWhenHidden = true; + new Handler(Looper.getMainLooper()).postDelayed(open, 100); + } + + /** Call from the activity's onStop. */ + public void onHostStopped() { + if (!closeWhenHidden) return; + closeWhenHidden = false; + binding.drawerLayout.closeDrawer(GravityCompat.START, false); } private void handleBackPressed() { @@ -260,7 +276,7 @@ public void destroy() { header.findViewById(R.id.nav_backups).setOnClickListener(null); header.findViewById(R.id.nav_about).setOnClickListener(null); header.findViewById(R.id.nav_support).setOnClickListener(null); - header.findViewById(R.id.drawerStatsCard).setOnClickListener(null); + header.findViewById(R.id.nav_meet_encly).setOnClickListener(null); View newVersion = header.findViewById(R.id.newVersion); if (newVersion != null) { diff --git a/app/src/main/java/com/pasich/mynotes/ui/controllers/mainActivity/ViewFader.java b/app/src/main/java/com/pasich/mynotes/ui/controllers/mainActivity/ViewFader.java new file mode 100644 index 00000000..e5243722 --- /dev/null +++ b/app/src/main/java/com/pasich/mynotes/ui/controllers/mainActivity/ViewFader.java @@ -0,0 +1,183 @@ +package com.pasich.mynotes.ui.controllers.mainActivity; + +import android.animation.Animator; +import android.animation.AnimatorListenerAdapter; +import android.animation.ObjectAnimator; +import android.animation.PropertyValuesHolder; +import android.animation.TimeInterpolator; +import android.view.View; +import androidx.annotation.Nullable; +import java.util.function.BooleanSupplier; + +/** + * Moves one view toward a shown or hidden target. + * + *

Every request starts from wherever the view is right now and cancels the animation that was + * running, so a hide that is still in flight can never finish after a later show and leave the view + * invisible. Asking for the target the view is already heading to is a no-op. With system + * animations turned off the target is applied immediately. + */ +final class ViewFader { + + private final View view; + private final int hiddenVisibility; + private final float hiddenScaleY; + private final BooleanSupplier animationsEnabled; + + @Nullable private Animator running; + @Nullable private Runnable onDimmed; + private float runningTargetAlpha = -1f; + private boolean targetShown; + private boolean dimmed; + + ViewFader( + View view, + int hiddenVisibility, + float hiddenScaleY, + BooleanSupplier animationsEnabled) { + this.view = view; + this.hiddenVisibility = hiddenVisibility; + this.hiddenScaleY = hiddenScaleY; + this.animationsEnabled = animationsEnabled; + this.targetShown = view.getVisibility() == View.VISIBLE; + } + + /** True while the view is meant to end up visible (including while it is dimmed). */ + boolean isTargetShown() { + return targetShown; + } + + /** True from {@link #dim} until the next {@link #show} or {@link #hide}. */ + boolean isDimmed() { + return dimmed; + } + + void show(boolean animate, long durationMs, TimeInterpolator interpolator) { + if (targetShown && !dimmed && (running != null || isAtRest(1f, 1f, View.VISIBLE))) { + return; + } + targetShown = true; + dimmed = false; + onDimmed = null; + cancel(); + if (view.getVisibility() != View.VISIBLE) { + view.setAlpha(0f); + view.setScaleY(hiddenScaleY); + view.setVisibility(View.VISIBLE); + } + animateTo(1f, 1f, animate, durationMs, interpolator, null); + } + + void hide(boolean animate, long durationMs, TimeInterpolator interpolator) { + if (!targetShown && (running != null || view.getVisibility() == hiddenVisibility)) { + return; + } + targetShown = false; + dimmed = false; + onDimmed = null; + cancel(); + if (view.getVisibility() != View.VISIBLE) { + settleHidden(); + return; + } + animateTo(0f, hiddenScaleY, animate, durationMs, interpolator, this::settleHidden); + } + + /** + * Fades a shown view out without hiding it, then runs {@code whenDimmed}. A second call while + * the fade is running only replaces the callback, so the newest one wins. When the view is not + * on screen, or animations are off, {@code whenDimmed} runs right away. + */ + void dim(boolean animate, long durationMs, TimeInterpolator interpolator, Runnable whenDimmed) { + if (!targetShown || view.getVisibility() != View.VISIBLE || !canAnimate(animate)) { + whenDimmed.run(); + return; + } + boolean alreadyDark = dimmed && running == null && view.getAlpha() == 0f; + dimmed = true; + if (alreadyDark) { + onDimmed = null; + whenDimmed.run(); + return; + } + onDimmed = whenDimmed; + // Already fading out: keep going and let the newest callback run at the end. + if (isHeadingTo(0f)) return; + cancel(); + animateTo( + 0f, + 1f, + true, + durationMs, + interpolator, + () -> { + Runnable callback = onDimmed; + onDimmed = null; + if (callback != null) callback.run(); + }); + } + + /** Stops any running animation without applying its end state. */ + void cancel() { + Animator animator = running; + running = null; + runningTargetAlpha = -1f; + if (animator != null) animator.cancel(); + } + + private boolean isHeadingTo(float alpha) { + return running != null && runningTargetAlpha == alpha; + } + + private boolean isAtRest(float alpha, float scaleY, int visibility) { + return view.getVisibility() == visibility + && view.getAlpha() == alpha + && view.getScaleY() == scaleY; + } + + private boolean canAnimate(boolean animate) { + return animate && animationsEnabled.getAsBoolean(); + } + + private void settleHidden() { + view.setVisibility(hiddenVisibility); + view.setAlpha(1f); + view.setScaleY(1f); + } + + private void animateTo( + float alpha, + float scaleY, + boolean animate, + long durationMs, + TimeInterpolator interpolator, + @Nullable Runnable endAction) { + if (!canAnimate(animate)) { + view.setAlpha(alpha); + view.setScaleY(scaleY); + if (endAction != null) endAction.run(); + return; + } + ObjectAnimator animator = + ObjectAnimator.ofPropertyValuesHolder( + view, + PropertyValuesHolder.ofFloat(View.ALPHA, alpha), + PropertyValuesHolder.ofFloat(View.SCALE_Y, scaleY)); + animator.setDuration(durationMs); + animator.setInterpolator(interpolator); + animator.addListener( + new AnimatorListenerAdapter() { + @Override + public void onAnimationEnd(Animator animation) { + // A cancelled or replaced animation is no longer `running`. + if (running != animation) return; + running = null; + runningTargetAlpha = -1f; + if (endAction != null) endAction.run(); + } + }); + running = animator; + runningTargetAlpha = alpha; + animator.start(); + } +} diff --git a/app/src/main/java/com/pasich/mynotes/ui/history/NoteVersionAdapter.java b/app/src/main/java/com/pasich/mynotes/ui/history/NoteVersionAdapter.java new file mode 100644 index 00000000..452c8451 --- /dev/null +++ b/app/src/main/java/com/pasich/mynotes/ui/history/NoteVersionAdapter.java @@ -0,0 +1,115 @@ +package com.pasich.mynotes.ui.history; + +import android.content.Context; +import android.view.LayoutInflater; +import android.view.ViewGroup; +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.DiffUtil; +import androidx.recyclerview.widget.ListAdapter; +import androidx.recyclerview.widget.RecyclerView; +import com.pasich.mynotes.R; +import com.pasich.mynotes.data.database.entities.NoteVersionEntity; +import com.pasich.mynotes.data.history.NoteHistory; +import com.pasich.mynotes.data.history.NoteVersionReason; +import com.pasich.mynotes.databinding.ItemNoteVersionBinding; +import com.pasich.mynotes.databinding.ItemNoteVersionHeaderBinding; + +/** One card per version of a note, newest first. */ +public class NoteVersionAdapter + extends ListAdapter { + + /** Called when a version is tapped. */ + public interface Listener { + void onVersionClick(@NonNull NoteVersionEntity version); + } + + @NonNull private final Listener listener; + + public NoteVersionAdapter(@NonNull Listener listener) { + super( + new DiffUtil.ItemCallback<>() { + @Override + public boolean areItemsTheSame( + @NonNull NoteVersionEntity a, @NonNull NoteVersionEntity b) { + return a.id == b.id; + } + + @Override + public boolean areContentsTheSame( + @NonNull NoteVersionEntity a, @NonNull NoteVersionEntity b) { + // A version is never edited once kept. + return a.id == b.id && a.createdAt == b.createdAt; + } + }); + this.listener = listener; + } + + @NonNull + @Override + public VersionHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + return new VersionHolder( + ItemNoteVersionBinding.inflate( + LayoutInflater.from(parent.getContext()), parent, false)); + } + + @Override + public void onBindViewHolder(@NonNull VersionHolder holder, int position) { + holder.bind(getItem(position)); + } + + final class VersionHolder extends RecyclerView.ViewHolder { + final ItemNoteVersionBinding binding; + + VersionHolder(@NonNull ItemNoteVersionBinding binding) { + super(binding.getRoot()); + this.binding = binding; + } + + void bind(@NonNull NoteVersionEntity version) { + Context context = itemView.getContext(); + binding.versionTime.setText(NoteVersionText.when(context, version.createdAt)); + binding.versionReason.setText( + NoteVersionText.reasonLabel(NoteVersionReason.fromStored(version.reason))); + String preview = NoteVersionText.readable(version.title, version.value); + binding.versionPreview.setText( + preview.isEmpty() ? context.getString(R.string.version_untitled) : preview); + binding.versionCard.setOnClickListener(v -> listener.onVersionClick(version)); + } + } + + /** The explanatory first row: where versions live and how many are kept. */ + public static final class Header extends RecyclerView.Adapter { + + @NonNull + @Override + public Holder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + return new Holder( + ItemNoteVersionHeaderBinding.inflate( + LayoutInflater.from(parent.getContext()), parent, false)); + } + + @Override + public void onBindViewHolder(@NonNull Holder holder, int position) { + holder.binding.headerText.setText( + holder.itemView + .getContext() + .getString( + R.string.version_history_info, + NoteHistory.MAX_VERSIONS_PER_NOTE)); + } + + @Override + public int getItemCount() { + return 1; + } + + static final class Holder extends RecyclerView.ViewHolder { + final ItemNoteVersionHeaderBinding binding; + + Holder(@NonNull ItemNoteVersionHeaderBinding binding) { + super(binding.getRoot()); + this.binding = binding; + } + } + } +} diff --git a/app/src/main/java/com/pasich/mynotes/ui/history/NoteVersionText.java b/app/src/main/java/com/pasich/mynotes/ui/history/NoteVersionText.java new file mode 100644 index 00000000..d1b35998 --- /dev/null +++ b/app/src/main/java/com/pasich/mynotes/ui/history/NoteVersionText.java @@ -0,0 +1,220 @@ +package com.pasich.mynotes.ui.history; + +import android.content.Context; +import android.text.Spannable; +import android.text.SpannableString; +import android.text.format.DateUtils; +import android.text.style.BackgroundColorSpan; +import android.text.style.ForegroundColorSpan; +import androidx.annotation.NonNull; +import androidx.annotation.StringRes; +import androidx.annotation.VisibleForTesting; +import com.google.android.material.color.MaterialColors; +import com.pasich.mynotes.R; +import com.pasich.mynotes.data.history.NoteVersionReason; +import com.pasich.mynotes.ui.sync.SyncConflictPresentation; +import java.util.ArrayList; +import java.util.List; + +/** Wording and formatting shared by the version list and the version preview. */ +public final class NoteVersionText { + + private NoteVersionText() {} + + /** What a person reads as the note: its title, then its text. */ + @NonNull + public static String readable(@NonNull String title, @NonNull String value) { + String head = title.trim(); + String body = value.trim(); + if (head.isEmpty()) return body; + if (body.isEmpty()) return head; + return head + "\n" + body; + } + + @StringRes + public static int reasonLabel(@NonNull NoteVersionReason reason) { + return switch (reason) { + case PRE_SYNC -> R.string.version_reason_pre_sync; + case PRE_RESTORE -> R.string.version_reason_pre_restore; + case PRE_CONFLICT -> R.string.version_reason_pre_conflict; + case AUTOSAVE -> R.string.version_reason_autosave; + }; + } + + /** + * "Just now, 14:05", "5 minutes ago, 14:05", "Yesterday, 14:05" or a date, in the user's + * locale. Under a minute the system's wording says "0 minutes ago". + */ + @NonNull + public static CharSequence when(@NonNull Context context, long time) { + if (isJustNow(System.currentTimeMillis(), time)) { + return context.getString( + R.string.version_time_just_now, + android.text.format.DateFormat.getTimeFormat(context).format(time)); + } + return DateUtils.getRelativeDateTimeString( + context, + time, + DateUtils.MINUTE_IN_MILLIS, + DateUtils.WEEK_IN_MILLIS, + DateUtils.FORMAT_ABBREV_MONTH); + } + + /** Whether a version kept at {@code time} is less than a minute old at {@code now}. */ + @VisibleForTesting + static boolean isJustNow(long now, long time) { + return Math.abs(now - time) < DateUtils.MINUTE_IN_MILLIS; + } + + /** + * Widens a highlighted range so it never starts or ends inside a word or a number: a change + * from "1200" to "1500" marks "1200" and "1500", not "2" and "5". An empty range inside a word + * (letters only added on the other side) marks that word. + * + * @return {@code {start, end}}. + */ + @NonNull + @VisibleForTesting + static int[] wholeWords(@NonNull CharSequence text, int start, int end) { + int length = text.length(); + int from = Math.max(0, Math.min(start, length)); + int to = Math.max(from, Math.min(end, length)); + while (from > 0 + && from < length + && isWordChar(text.charAt(from - 1)) + && isWordChar(text.charAt(from))) { + from--; + } + while (to > 0 + && to < length + && isWordChar(text.charAt(to - 1)) + && isWordChar(text.charAt(to))) { + to++; + } + return new int[] {from, to}; + } + + private static boolean isWordChar(char c) { + return Character.isLetterOrDigit(c); + } + + /** Most line pairs compared one by one; longer notes are compared as one block. */ + @VisibleForTesting static final int MAX_LINE_PAIRS = 250_000; + + /** + * The parts of {@code text} that differ from {@code other}, as {@code {start, end}} ranges in + * {@code text}, each widened to whole words. + * + *

The lines of both texts are matched first (longest common subsequence), so equal lines + * stay unmarked however far apart the changes are; inside each run of changed lines only the + * part between the common start and end is marked. Texts with too many lines to match fall back + * to a single range from the first to the last difference. + */ + @NonNull + @VisibleForTesting + static List changedRanges(@NonNull String text, @NonNull String other) { + List ranges = new ArrayList<>(); + String[] a = text.split("\n", -1); + String[] b = other.split("\n", -1); + int n = a.length; + int m = b.length; + if ((long) n * m > MAX_LINE_PAIRS) { + addChange(ranges, text, 0, text.length(), other); + return ranges; + } + int[] starts = new int[n + 1]; + for (int i = 0; i < n; i++) starts[i + 1] = starts[i] + a[i].length() + 1; + // common[i][j]: longest common run of lines of a[i..] and b[j..]. + int[][] common = new int[n + 1][m + 1]; + for (int i = n - 1; i >= 0; i--) { + for (int j = m - 1; j >= 0; j--) { + common[i][j] = + a[i].equals(b[j]) + ? common[i + 1][j + 1] + 1 + : Math.max(common[i + 1][j], common[i][j + 1]); + } + } + int i = 0; + int j = 0; + while (i < n || j < m) { + if (i < n && j < m && a[i].equals(b[j])) { + i++; + j++; + continue; + } + int fromA = i; + int fromB = j; + while ((i < n || j < m) && !(i < n && j < m && a[i].equals(b[j]))) { + if (j < m && (i == n || common[i][j + 1] >= common[i + 1][j])) { + j++; + } else { + i++; + } + } + if (i > fromA) { + int start = starts[fromA]; + int end = Math.min(text.length(), starts[i] - 1); + addChange(ranges, text, start, end, String.join("\n", sub(b, fromB, j))); + } + } + return ranges; + } + + private static String[] sub(String[] lines, int from, int to) { + String[] out = new String[to - from]; + System.arraycopy(lines, from, out, 0, to - from); + return out; + } + + /** Marks what differs between {@code text[start, end)} and {@code replaced}. */ + private static void addChange( + List ranges, String text, int start, int end, String replaced) { + String part = text.substring(start, end); + int[] diff = SyncConflictPresentation.differenceRange(part, replaced); + int[] range = wholeWords(text, start + diff[0], start + diff[1]); + if (range[1] > range[0]) ranges.add(range); + } + + /** + * {@code text} with the parts that differ from {@code other} marked in the theme's primary + * colours. Longer than {@code limit}, it is cut to a window around its first change. + */ + @NonNull + public static CharSequence highlighted( + @NonNull Context context, @NonNull String text, @NonNull String other, int limit) { + List ranges = changedRanges(text, other); + String shown = text; + int shift = 0; + if (text.length() > limit) { + int[] first = ranges.isEmpty() ? new int[] {0, 0} : ranges.get(0); + SyncConflictPresentation.Window window = + SyncConflictPresentation.window(text, first[0], first[1], limit); + shown = window.text; + shift = window.start - first[0]; + } + if (ranges.isEmpty()) return shown; + SpannableString marked = new SpannableString(shown); + int container = + MaterialColors.getColor( + context, com.google.android.material.R.attr.colorPrimaryContainer, 0); + int onContainer = + MaterialColors.getColor( + context, com.google.android.material.R.attr.colorOnPrimaryContainer, 0); + for (int[] range : ranges) { + int from = Math.max(0, range[0] + shift); + int to = Math.min(shown.length(), range[1] + shift); + if (to <= from) continue; + marked.setSpan( + new BackgroundColorSpan(container), + from, + to, + Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); + marked.setSpan( + new ForegroundColorSpan(onContainer), + from, + to, + Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); + } + return marked; + } +} diff --git a/app/src/main/java/com/pasich/mynotes/ui/presenter/MainPresenter.java b/app/src/main/java/com/pasich/mynotes/ui/presenter/MainPresenter.java index 7d9edefa..aa3582ee 100644 --- a/app/src/main/java/com/pasich/mynotes/ui/presenter/MainPresenter.java +++ b/app/src/main/java/com/pasich/mynotes/ui/presenter/MainPresenter.java @@ -7,6 +7,7 @@ import com.pasich.mynotes.data.DataManager; import com.pasich.mynotes.data.model.Note; import com.pasich.mynotes.data.model.Tag; +import com.pasich.mynotes.data.order.CustomOrder; import com.pasich.mynotes.ui.contract.MainContract; import com.pasich.mynotes.ui.state.MainViewState; import com.pasich.mynotes.ui.state.StatsData; @@ -15,18 +16,19 @@ import com.pasich.mynotes.utils.constants.settings.SortParam; import com.pasich.mynotes.utils.managers.SystemTagsManager; import com.pasich.mynotes.utils.rx.SchedulerProvider; +import com.pasich.mynotes.utils.search.NoteSearchRanker; +import com.pasich.mynotes.utils.search.SearchHit; import dagger.hilt.android.scopes.ActivityScoped; import io.reactivex.Flowable; import io.reactivex.Observable; import io.reactivex.disposables.CompositeDisposable; import io.reactivex.subjects.BehaviorSubject; import java.util.ArrayList; -import java.util.Collections; +import java.util.Comparator; import java.util.HashSet; import java.util.List; import java.util.Set; import java.util.concurrent.TimeUnit; -import java.util.stream.Collectors; import javax.inject.Inject; /** Presenter backing the main notes list screen. */ @@ -35,6 +37,16 @@ public class MainPresenter extends BasePresenter implements MainContract.presenter { private static final String TAG = "MainPresenter"; + + /** + * Room emits tags and notes separately, so one sync or edit can arrive as two or more emissions + * a few milliseconds apart. Waiting this long folds them into a single render. + */ + static final long STATE_DEBOUNCE_MS = 50; + + /** Folds the burst of a note edit and a query change into one ranking pass. */ + static final long SEARCH_DEBOUNCE_MS = 150; + private final Handler uiHandler = new Handler(Looper.getMainLooper()); private final BehaviorSubject selectedTag = BehaviorSubject.createDefault(SystemTagsManager.createAllNotesTag()); @@ -43,6 +55,8 @@ public class MainPresenter extends BasePresenter private final BehaviorSubject searchQuery = BehaviorSubject.createDefault(""); private final BehaviorSubject searchTagFilter = BehaviorSubject.createDefault(""); + private final NoteSearchRanker searchRanker = new NoteSearchRanker(); + private UiEvent lastUiEvent = UiEvent.NONE; private Note backupDeleteNote; private Observable> tagsStream; @@ -97,28 +111,13 @@ void starListsRenderStream() { private void initStreams() { tagsStream = - Observable.combineLatest( - getDataManager() - .getTags() - .toObservable() - .map( - tagList -> - TagsSorter.sortTags( - tagList, - getDataManager().getSortParamTags())), - selectedTag, - (tags, selected) -> - tags.stream() - .map( - t -> { - Tag copy = t.copy(); - copy.setSelected( - selected != null - && selected.getId() - == t.getId()); - return copy; - }) - .collect(Collectors.toList())); + getDataManager() + .getTags() + .toObservable() + .map( + tagList -> + TagsSorter.sortTags( + tagList, getDataManager().getSortParamTags())); notesStream = getDataManager().getNotes().toObservable(); } @@ -132,7 +131,10 @@ private void startStateCombiner() { selectedTag, sortParam, this::buildState) - .debounce(5, TimeUnit.MILLISECONDS) + .debounce( + STATE_DEBOUNCE_MS, + TimeUnit.MILLISECONDS, + getSchedulerProvider().computation()) .distinctUntilChanged() .subscribeOn(getSchedulerProvider().io()) .observeOn(getSchedulerProvider().ui()) @@ -142,7 +144,21 @@ private void startStateCombiner() { } private MainViewState buildState( - List tags, List notes, Tag selectedTag, String sort) { + List sortedTags, List notes, Tag requestedTag, String sort) { + + Tag selectedTag = resolveSelectedTag(sortedTags, requestedTag); + if (!sameSelection(selectedTag, requestedTag)) { + // The tag was renamed, merged or deleted by a sync: remember the fresh one so new + // notes, the next state and the chips all agree with what is on screen. + this.selectedTag.onNext(selectedTag); + } + + List tags = new ArrayList<>(sortedTags.size()); + for (Tag t : sortedTags) { + Tag copy = t.copy(); + copy.setSelected(t.getId() == selectedTag.getId()); + tags.add(copy); + } Set hidden = new HashSet<>(); for (Tag t : tags) { @@ -162,10 +178,7 @@ private MainViewState buildState( } } - boolean isAllNotes = - selectedTag == null - || selectedTag.getSystemAction() - == SystemTagsManager.SYSTEM_ACTION_ALL_NOTES; + boolean isAllNotes = SystemTagsManager.isAllNotesTag(selectedTag); List filtered; if (isAllNotes) { @@ -181,18 +194,74 @@ private MainViewState buildState( } List sorted = new ArrayList<>(filtered); + sorted.sort(comparatorFor(sort)); + + return new MainViewState(tags, sorted, selectedTag, lastUiEvent); + } - // pinned always first, then by date + /** Pinned notes always first; then the custom order, or the date of the last edit. */ + @androidx.annotation.VisibleForTesting + public static Comparator comparatorFor(String sort) { + if (SortParam.Custom.equals(sort)) return CustomOrder.COMPARATOR; boolean sortByNew = SortParam.DataSort.equals(sort); - sorted.sort( - (a, b) -> { - if (a.isPinned() != b.isPinned()) return a.isPinned() ? -1 : 1; - return sortByNew - ? Long.compare(b.getDate(), a.getDate()) - : Long.compare(a.getDate(), b.getDate()); - }); + return (a, b) -> { + if (a.isPinned() != b.isPinned()) return a.isPinned() ? -1 : 1; + return sortByNew + ? Long.compare(b.getDate(), a.getDate()) + : Long.compare(a.getDate(), b.getDate()); + }; + } - return new MainViewState(tags, sorted, selectedTag, lastUiEvent); + /** Whether the list is in the custom order, where notes can be dragged. */ + @Override + public boolean isCustomOrder() { + return SortParam.Custom.equals(sortParam.getValue()); + } + + @Override + public void moveNoteInCustomOrder(int noteId, Integer upperId, Integer lowerId) { + getCompositeDisposable() + .add( + getDataManager() + .moveNoteInCustomOrder(noteId, upperId, lowerId) + .subscribeOn(getSchedulerProvider().io()) + .observeOn(getSchedulerProvider().ui()) + .subscribe( + () -> {}, + throwable -> Log.e(TAG, "Error moving note", throwable))); + } + + /** + * Finds the selected category in a fresh tags list: by id first (survives a rename), then by + * name (survives a sync that replaced the row with an equal one), otherwise "All notes". + */ + static Tag resolveSelectedTag(List tags, Tag requested) { + Tag allNotes = null; + for (Tag t : tags) { + if (SystemTagsManager.isAllNotesTag(t)) { + allNotes = t; + break; + } + } + if (allNotes == null) allNotes = SystemTagsManager.createAllNotesTag(); + + if (requested == null || SystemTagsManager.isSystemTag(requested)) return allNotes; + + for (Tag t : tags) { + if (!SystemTagsManager.isSystemTag(t) && t.getId() == requested.getId()) return t; + } + String name = requested.getNameTag(); + for (Tag t : tags) { + if (!SystemTagsManager.isSystemTag(t) && t.getNameTag().equals(name)) return t; + } + return allNotes; + } + + private static boolean sameSelection(Tag a, Tag b) { + if (a == null || b == null) return a == b; + return a.getId() == b.getId() + && a.getSystemAction() == b.getSystemAction() + && a.getNameTag().equals(b.getNameTag()); } private void startSearchStream() { @@ -202,38 +271,48 @@ private void startSearchStream() { notesStream, searchQuery, searchTagFilter, - this::filterNotes) - .debounce(150, TimeUnit.MILLISECONDS) + SearchRequest::new) + .debounce( + SEARCH_DEBOUNCE_MS, + TimeUnit.MILLISECONDS, + getSchedulerProvider().computation()) + .map(this::runSearch) .subscribeOn(getSchedulerProvider().io()) .observeOn(getSchedulerProvider().ui()) .subscribe( - filtered -> getView().renderSearch(filtered), + result -> + getView() + .renderSearch( + result.hits, result.titlesOnly), throwable -> Log.e(TAG, "search error", throwable))); } - private List filterNotes(List notes, String query, String tagFilter) { - if (query == null || query.trim().length() < 2) return Collections.emptyList(); + /** Ranks off the main thread: the debounce hands the latest request to a worker. */ + private SearchResult runSearch(SearchRequest request) { + List hits = searchRanker.rank(request.notes, request.query, request.tagFilter); + return new SearchResult(hits, NoteSearchRanker.searchesTitlesOnly(request.query)); + } - String q = query.toLowerCase().trim(); - boolean hasTagFilter = tagFilter != null && !tagFilter.isEmpty(); + private static final class SearchRequest { + final List notes; + final String query; + final String tagFilter; - return notes.stream() - .filter( - n -> - (n.getTitle().toLowerCase().contains(q) - || n.getValue().toLowerCase().contains(q)) - && (!hasTagFilter || tagFilter.equals(n.getTag()))) - .sorted( - (n1, n2) -> { - boolean n1Exact = n1.getTitle().equalsIgnoreCase(query); - boolean n2Exact = n2.getTitle().equalsIgnoreCase(query); + SearchRequest(List notes, String query, String tagFilter) { + this.notes = notes; + this.query = query; + this.tagFilter = tagFilter; + } + } - if (n1Exact && !n2Exact) return -1; - if (!n1Exact && n2Exact) return 1; + private static final class SearchResult { + final List hits; + final boolean titlesOnly; - return Long.compare(n2.getDate(), n1.getDate()); - }) - .collect(Collectors.toList()); + SearchResult(List hits, boolean titlesOnly) { + this.hits = hits; + this.titlesOnly = titlesOnly; + } } public void updateSearchQuery(String query) { @@ -247,12 +326,14 @@ public void updateSearchTagFilter(String tagName) { @Override public void onSortChanged(String newSort) { + if (newSort == null || newSort.equals(sortParam.getValue())) return; lastUiEvent = UiEvent.SORT_CHANGED; sortParam.onNext(newSort); } @Override public void onTagSelected(Tag tag) { + if (tag == null || sameSelection(tag, selectedTag.getValue())) return; lastUiEvent = UiEvent.TAG_CHANGED; selectedTag.onNext(tag); } diff --git a/app/src/main/java/com/pasich/mynotes/ui/presenter/NotePresenter.java b/app/src/main/java/com/pasich/mynotes/ui/presenter/NotePresenter.java index 471774c5..d4321b79 100644 --- a/app/src/main/java/com/pasich/mynotes/ui/presenter/NotePresenter.java +++ b/app/src/main/java/com/pasich/mynotes/ui/presenter/NotePresenter.java @@ -15,6 +15,7 @@ import com.pasich.mynotes.utils.navigation.NoteExtras; import com.pasich.mynotes.utils.rx.SchedulerProvider; import io.reactivex.disposables.CompositeDisposable; +import io.reactivex.disposables.Disposable; import io.reactivex.disposables.SerialDisposable; import io.reactivex.subjects.PublishSubject; import java.util.Date; @@ -39,6 +40,12 @@ public class NotePresenter extends BasePresenter private boolean pendingClose = false; // Note downloaded from the database private Note targetNote; + // True while the note's plain text and attachment list still describe an older extended-editor + // document than its valueJson. They are derived at save time instead of on every change. + private boolean documentDerivedStale = false; + // A save started by flushPending(). Kept out of the composite on purpose: the screen is often + // being destroyed right after onStop, and disposing the composite must not cancel this write. + private Disposable flushSave; @Inject public NotePresenter( @@ -76,11 +83,13 @@ public void setIdKey(long idKey) { } public Note getNote() { + syncDocumentDerivedFields(); return targetNote; } public void setNote(Note mNote) { this.targetNote = mNote; + this.documentDerivedStale = false; } public boolean getNewNotesKey() { @@ -118,7 +127,9 @@ public boolean hasNote() { */ @Override public void onNoteChanged() { - if (!hasMeaningfulContent(targetNote)) { + // A pending extended-editor document is judged when it is saved, so typing never pays + // for parsing the whole document. + if (!documentDerivedStale && !hasMeaningfulContent(targetNote)) { updateSaveState(SaveState.IDLE); return; } @@ -128,40 +139,93 @@ public void onNoteChanged() { private void performAutoSave() { if (targetNote != null && !isViewDead()) { - saveNote( - targetNote, - new NoteContract.AutoSaveCallback() { - @Override - public void onSuccess() { - updateSaveState(SaveState.SAVED); - if (!isViewDead()) { - getView().runAttachmentsCleanup(targetNote); - } - idleTimer.set( - io.reactivex.Observable.timer( - 3, - TimeUnit.SECONDS, - getSchedulerProvider().computation()) - .observeOn(getSchedulerProvider().ui()) - .subscribe(ignored -> updateSaveState(SaveState.IDLE))); - } - - @Override - public void onError(Throwable error) { - updateSaveState(SaveState.ERROR); - idleTimer.set( - io.reactivex.Observable.timer( - 5, - TimeUnit.SECONDS, - getSchedulerProvider().computation()) - .observeOn(getSchedulerProvider().ui()) - .subscribe( - ignored -> updateSaveState(SaveState.PENDING))); - } - }); + saveNote(targetNote, autoSaveCallback()); } } + /** Status handling shared by the debounced autosave and {@link #flushPending()}. */ + private NoteContract.AutoSaveCallback autoSaveCallback() { + return new NoteContract.AutoSaveCallback() { + @Override + public void onSuccess() { + updateSaveState(SaveState.SAVED); + if (!isViewDead()) { + getView().runAttachmentsCleanup(targetNote); + } + idleTimer.set( + io.reactivex.Observable.timer( + 3, TimeUnit.SECONDS, getSchedulerProvider().computation()) + .observeOn(getSchedulerProvider().ui()) + .subscribe(ignored -> updateSaveState(SaveState.IDLE))); + } + + @Override + public void onError(Throwable error) { + updateSaveState(SaveState.ERROR); + idleTimer.set( + io.reactivex.Observable.timer( + 5, TimeUnit.SECONDS, getSchedulerProvider().computation()) + .observeOn(getSchedulerProvider().ui()) + .subscribe(ignored -> updateSaveState(SaveState.PENDING))); + } + }; + } + + /** + * Writes the note now if it holds anything the debounced autosave has not written yet. + * + *

Called from the editors' onStop. Without it, whatever was typed in the last two seconds + * before the screen went away (home, rotation, the file picker, a phone call) lived only in the + * pending debounce and was lost with the activity. The write is not tied to the screen's + * lifetime, so a destroy right after onStop cannot cancel it. + */ + @Override + public void flushPending() { + if (targetNote == null) return; + syncDocumentDerivedFields(); + if (!hasMeaningfulContent(targetNote) || !isContentChanged()) return; + + Note note = targetNote; + note.setDate(new Date().getTime()); + NoteContract.AutoSaveCallback callback = autoSaveCallback(); + updateSaveState(SaveState.SAVING); + if (flushSave != null) flushSave.dispose(); + flushSave = + getDataManager() + .updateNote(note) + .subscribeOn(getSchedulerProvider().io()) + .observeOn(getSchedulerProvider().ui()) + .subscribe( + () -> { + savedNote.copyFrom(note); + callback.onSuccess(); + }, + error -> { + Log.e(TAG, "flushPending() failed", error); + callback.onError(error); + }); + } + + @Override + public boolean hasUnsavedChanges() { + if (targetNote == null) return false; + syncDocumentDerivedFields(); + return hasMeaningfulContent(targetNote) && isContentChanged(); + } + + /** + * Brings the note's plain text and attachment list up to date with its extended-editor + * document. Parsing the document is linear in its size, so it runs once per save instead of + * once per change. + */ + private void syncDocumentDerivedFields() { + if (!documentDerivedStale || targetNote == null) return; + documentDerivedStale = false; + ParsedNote parsed = EditorJsonUtils.extendedNoteToOldNote(targetNote.getValueJson()); + targetNote.setValue(parsed.plainText); + targetNote.setAttachments(parsed.toAttachmentsJson()); + } + /** * Saves a note (updates an existing one). * @@ -191,6 +255,7 @@ private void saveNote(Note note, NoteContract.AutoSaveCallback callback) { callback.onError(new Exception("Note is null")); return; } + syncDocumentDerivedFields(); // Check if Activity is still alive // Activity is destroyed, but there are changes - perform emergency saving @@ -201,10 +266,11 @@ private void saveNote(Note note, NoteContract.AutoSaveCallback callback) { return; } - // Check if there is valid content to save + // Nothing worth saving. The callback is not told it succeeded: it would clean the + // attachment folder against a list that was never written, deleting files the stored + // note still references. if (!hasMeaningfulContent(note)) { updateSaveState(SaveState.IDLE); - callback.onSuccess(); return; } @@ -239,6 +305,7 @@ private void saveNote(Note note, NoteContract.AutoSaveCallback callback) { * synchronously to ensure saving. Only NoteActivity (SimpleEditor) */ private void performEmergencySave(Note note) { + syncDocumentDerivedFields(); try { boolean hasChanges = !note.getTitle().equals(savedNote.getTitle()) || isContentChanged(); @@ -286,6 +353,7 @@ private void updateSaveState(SaveState newState) { */ @Override public void closeActivity() { + syncDocumentDerivedFields(); // Case: new empty note → delete and exit if (targetNote != null && newNoteKey && !hasMeaningfulContent(targetNote)) { @@ -440,6 +508,7 @@ public void loadingData(long idNote) { setNote(note); // Update saved values on load savedNote.copyFrom(note); + getView().resetEditHistory(); updateSaveState(SaveState.IDLE); } }, @@ -447,6 +516,31 @@ public void loadingData(long idNote) { Log.e(TAG, "loadingData() failed", throwable))); } + @Override + public void reloadNote() { + if (targetNote == null) return; + getCompositeDisposable() + .add( + getDataManager() + .getNoteForId(targetNote.getId()) + .subscribeOn(getSchedulerProvider().io()) + .observeOn(getSchedulerProvider().ui()) + .subscribe( + note -> { + if (note == null || note.getId() == 0 || isViewDead()) + return; + setNote(note); + savedNote.copyFrom(note); + if (getExtendedEditor()) { + getView().reloadExtendedEditor(); + } + getView().loadingNote(note); + getView().resetEditHistory(); + updateSaveState(SaveState.IDLE); + }, + throwable -> Log.e(TAG, "reloadNote() failed", throwable))); + } + @Override public void activateEditNote() { getView().activatedActivity(); @@ -483,24 +577,22 @@ private boolean isViewDead() { * * @param title Optional updated title. * @param jsonData Optional Editor.js JSON containing blocks. - *

If title is provided → update title. If jsonData is provided → parse blocks into: - - * plain text → value - attachments → attachments JSON - raw blocks JSON → valueJson - *

Marks the note as rich-content and triggers auto-save. + *

If title is provided → update title. If jsonData is provided → it becomes valueJson at + * once; the plain text and attachment list derived from it are brought up to date when the + * note is next saved or read through {@link #getNote()}. + *

Triggers auto-save. */ @Override public void extendedNoteChange(String title, String jsonData) { - if (getNote() == null || title == null && jsonData == null) return; + if (targetNote == null || title == null && jsonData == null) return; if (title != null) { - getNote().setTitle(title); + targetNote.setTitle(title); } if (jsonData != null) { - ParsedNote parsed = EditorJsonUtils.extendedNoteToOldNote(jsonData); - String attachmentsJson = parsed.toAttachmentsJson(); - getNote().setValue(parsed.plainText); - getNote().setAttachments(attachmentsJson); - getNote().setValueJson(jsonData); + targetNote.setValueJson(jsonData); + documentDerivedStale = true; } onNoteChanged(); @@ -542,6 +634,7 @@ public void simpleNoteChange(String title, String value, boolean emergencySave) @Override public void copyNoteRequest() { if (targetNote == null) return; + syncDocumentDerivedFields(); getCompositeDisposable() .add( @@ -565,7 +658,7 @@ public void copyNoteRequest() { .ui()) .subscribe( note -> { - targetNote = note; + setNote(note); savedNote.copyFrom( note); idKey = newId; @@ -581,6 +674,8 @@ public void copyNoteRequest() { getView() .loadingNote( note); + getView() + .resetEditHistory(); getView() .onNoteCopied( newId); diff --git a/app/src/main/java/com/pasich/mynotes/ui/receiver/BootReceiver.java b/app/src/main/java/com/pasich/mynotes/ui/receiver/BootReceiver.java index c5fc7767..fab47dc2 100644 --- a/app/src/main/java/com/pasich/mynotes/ui/receiver/BootReceiver.java +++ b/app/src/main/java/com/pasich/mynotes/ui/receiver/BootReceiver.java @@ -1,43 +1,48 @@ package com.pasich.mynotes.ui.receiver; +import android.app.AlarmManager; import android.content.BroadcastReceiver; import android.content.Context; import android.content.Intent; import android.util.Log; -import com.pasich.mynotes.data.DataManager; -import com.pasich.mynotes.utils.reminder.ReminderManager; -import com.pasich.mynotes.utils.reminder.TaskReminderManager; +import com.pasich.mynotes.utils.reminder.ReminderRescheduler; import dagger.hilt.android.AndroidEntryPoint; -import io.reactivex.Single; +import io.reactivex.schedulers.Schedulers; import javax.inject.Inject; -/** BroadcastReceiver that reschedules all reminders after device reboot. */ +/** + * Re-arms every reminder when armed alarms were lost or can now be exact: after a reboot, after the + * app was updated, and when "Alarms & reminders" is granted (the system then delivers this so + * alarms armed inexactly meanwhile become exact). + */ @AndroidEntryPoint public class BootReceiver extends BroadcastReceiver { private static final String TAG = "BootReceiver"; - @Inject DataManager dataManager; + @Inject ReminderRescheduler reminderRescheduler; @Override public void onReceive(Context ctx, Intent intent) { - if (!Intent.ACTION_BOOT_COMPLETED.equals(intent.getAction())) return; + String action = intent.getAction(); + if (!Intent.ACTION_BOOT_COMPLETED.equals(action) + && !Intent.ACTION_MY_PACKAGE_REPLACED.equals(action) + && !AlarmManager.ACTION_SCHEDULE_EXACT_ALARM_PERMISSION_STATE_CHANGED.equals( + action)) { + return; + } final PendingResult pendingResult = goAsync(); - - Single.zip( - dataManager.getNotesWithActiveReminders(), - dataManager.getTasksWithReminders(), - (notes, tasks) -> { - ReminderManager.rescheduleAll(ctx, notes); - TaskReminderManager.rescheduleAll(ctx, tasks); - return true; - }) - .subscribe( - result -> pendingResult.finish(), - e -> { - Log.e(TAG, "reschedule failed", e); - pendingResult.finish(); + Schedulers.io() + .scheduleDirect( + () -> { + try { + reminderRescheduler.rescheduleAll(System.currentTimeMillis()); + } catch (RuntimeException e) { + Log.e(TAG, "reschedule failed", e); + } finally { + pendingResult.finish(); + } }); } } diff --git a/app/src/main/java/com/pasich/mynotes/ui/receiver/ReminderReceiver.java b/app/src/main/java/com/pasich/mynotes/ui/receiver/ReminderReceiver.java index 85284f4c..4eb23795 100644 --- a/app/src/main/java/com/pasich/mynotes/ui/receiver/ReminderReceiver.java +++ b/app/src/main/java/com/pasich/mynotes/ui/receiver/ReminderReceiver.java @@ -5,155 +5,168 @@ import android.content.Context; import android.content.Intent; import android.util.Log; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.WorkerThread; import androidx.core.app.NotificationCompat; import androidx.core.app.NotificationManagerCompat; import androidx.core.app.TaskStackBuilder; import com.pasich.mynotes.R; import com.pasich.mynotes.cache.NotificationPreferencesCache; +import com.pasich.mynotes.cache.ThemePreferencesCache; import com.pasich.mynotes.data.DataManager; import com.pasich.mynotes.data.model.Note; -import com.pasich.mynotes.data.model.ReminderRepeat; +import com.pasich.mynotes.data.model.RepeatRule; import com.pasich.mynotes.ui.view.activity.MainActivity; import com.pasich.mynotes.ui.view.activity.ReminderTapActivity; import com.pasich.mynotes.ui.view.activity.SnoozeActivity; -import com.pasich.mynotes.ui.view.activity.noteEditor.NoteActivity; import com.pasich.mynotes.utils.navigation.NoteExtras; +import com.pasich.mynotes.utils.navigation.NoteNavigator; import com.pasich.mynotes.utils.reminder.ReminderManager; +import com.pasich.mynotes.utils.reminder.SnoozeStore; import dagger.hilt.android.AndroidEntryPoint; +import io.reactivex.schedulers.Schedulers; import javax.inject.Inject; -/** BroadcastReceiver that fires reminder notifications and reschedules repeating alarms. */ +/** + * Posts note reminder notifications and moves a repeating reminder to its next occurrence. + * + *

Each alarm is checked against the note as stored now, not as it was when the alarm was armed: + * a note deleted, trashed or given another time meanwhile (here or by sync) is not announced by an + * alarm that no longer describes it. + */ @AndroidEntryPoint public class ReminderReceiver extends BroadcastReceiver { private static final String TAG = "ReminderReceiver"; + /** An alarm arriving this much before the stored time is stale and is re-armed instead. */ + private static final long EARLY_TOLERANCE_MS = 60_000L; + @Inject DataManager dataManager; @Inject NotificationPreferencesCache notificationPreferencesCache; + @Inject ThemePreferencesCache themePreferencesCache; + public static final String ACTION_DISMISS = "com.pasich.mynotes.ACTION_DISMISS_REMINDER"; @Override public void onReceive(Context ctx, Intent intent) { int noteId = intent.getIntExtra(ReminderManager.EXTRA_NOTE_ID, -1); if (noteId == -1) return; + Context app = ctx.getApplicationContext(); if (ACTION_DISMISS.equals(intent.getAction())) { - String dRepeat = intent.getStringExtra(ReminderManager.EXTRA_NOTE_REPEAT); - int dInterval = intent.getIntExtra(ReminderManager.EXTRA_NOTE_INTERVAL_MINUTES, 0); - String dTitle = intent.getStringExtra(ReminderManager.EXTRA_NOTE_TITLE); - String dPreview = intent.getStringExtra(ReminderManager.EXTRA_NOTE_PREVIEW); - cancelIntervalReminder(ctx, noteId, dRepeat, dInterval, dTitle, dPreview); + // Ends the "repeat notification" cycle only; the schedule moved on when it fired. + ReminderManager.cancelNag(app, noteId); + NotificationManagerCompat.from(app).cancel(noteId); return; } - String title = intent.getStringExtra(ReminderManager.EXTRA_NOTE_TITLE); - String preview = intent.getStringExtra(ReminderManager.EXTRA_NOTE_PREVIEW); - String repeatStr = intent.getStringExtra(ReminderManager.EXTRA_NOTE_REPEAT); - int intervalMinutes = intent.getIntExtra(ReminderManager.EXTRA_NOTE_INTERVAL_MINUTES, 0); + PendingResult pending = goAsync(); + Schedulers.io() + .scheduleDirect( + () -> { + try { + handle(app, intent, noteId, System.currentTimeMillis()); + } catch (RuntimeException e) { + Log.e(TAG, "reminder handling failed", e); + } finally { + pending.finish(); + } + }); + } - Log.d( - TAG, - "onReceive: noteId=" - + noteId - + " repeatStr=" - + repeatStr - + " intervalMinutes=" - + intervalMinutes); + @WorkerThread + private void handle(Context ctx, Intent intent, int noteId, long now) { + String action = intent.getAction(); + int alarmNag = intent.getIntExtra(ReminderManager.EXTRA_NOTE_INTERVAL_MINUTES, 0); + Note note = loadNote(noteId); - showNotification(ctx, noteId, title, preview, repeatStr, intervalMinutes); + if (ReminderManager.ACTION_NAG.equals(action)) { + if (note == null || note.isTrash()) return; + showNotification(ctx, note, alarmNag); + ReminderManager.scheduleNag(ctx, noteId, alarmNag, now); + return; + } - if (intervalMinutes > 0) { - long nextTime = System.currentTimeMillis() + intervalMinutes * 60_000L; - Log.d(TAG, "onReceive: INTERVAL path → nextTime=" + new java.util.Date(nextTime)); - dataManager - .updateNoteReminderFull( - noteId, - nextTime, - repeatStr != null ? repeatStr : "NONE", - intervalMinutes) - .subscribe(() -> {}, e -> Log.e(TAG, "updateReminderFull failed", e)); - Note tempNote = new Note(); - tempNote.setId(noteId); - tempNote.setTitle(title != null ? title : ""); - tempNote.setValue(preview != null ? preview : ""); - tempNote.setReminderTime(nextTime); - tempNote.setReminderRepeat(repeatStr != null ? repeatStr : "NONE"); - tempNote.setReminderIntervalMinutes(intervalMinutes); - ReminderManager.scheduleReminder(ctx, tempNote); + if (ReminderManager.ACTION_SNOOZE.equals(action)) { + SnoozeStore snoozes = new SnoozeStore(ctx); + SnoozeStore.Entry entry = snoozes.get(noteId); + long scheduledAt = intent.getLongExtra(ReminderManager.EXTRA_SCHEDULED_AT, -1L); + // Re-armed by the rescheduler after it already fired, or replaced by a later snooze. + if (entry == null || entry.time != scheduledAt) return; + snoozes.remove(noteId); + if (note == null || note.isTrash()) return; + showNotification(ctx, note, entry.intervalMinutes); + if (entry.intervalMinutes > 0) { + ReminderManager.scheduleNag(ctx, noteId, entry.intervalMinutes, now); + } + return; + } + + if (note == null || note.isTrash() || note.getReminderTime() == null) { + ReminderManager.cancelNag(ctx, noteId); + return; + } + long due = note.getReminderTime(); + if (due > now + EARLY_TOLERANCE_MS) { + // Armed for a time the note no longer has, typically moved by sync; wait for it. + ReminderManager.scheduleReminder(ctx, note); return; } - ReminderRepeat repeat = ReminderRepeat.from(repeatStr); - if (repeat == ReminderRepeat.NONE) { - Log.d(TAG, "onReceive: ONE-TIME path → clearing reminder for noteId=" + noteId); - dataManager - .clearReminder(noteId) - .subscribe(() -> {}, e -> Log.e(TAG, "clearReminder failed", e)); + int nag = note.getReminderIntervalMinutes(); + showNotification(ctx, note, nag); + + RepeatRule rule = RepeatRule.parse(note.getReminderRepeat()); + if (rule.isRepeating()) { + // Counted from the anchor, so neither a late delivery nor days of downtime shift it, + // and missed periods are skipped rather than announced one after another. + long next = rule.next(rule.anchorOr(due), Math.max(now, due)); + Log.d(TAG, "repeat " + note.getReminderRepeat() + " → next at " + next); + note.setReminderTime(next); + try { + dataManager + .updateNoteReminderFull(noteId, next, note.getReminderRepeat(), nag) + .blockingAwait(); + } catch (RuntimeException e) { + Log.e(TAG, "advancing the reminder failed", e); + } + ReminderManager.scheduleReminder(ctx, note); } else { - long nextTime = ReminderManager.computeNextTime(System.currentTimeMillis(), repeat); - Log.d( - TAG, - "onReceive: REPEAT(" - + repeat - + ") path → scheduling next at " - + new java.util.Date(nextTime)); - dataManager - .updateNoteReminderFull(noteId, nextTime, repeat.name(), 0) - .subscribe(() -> {}, e -> Log.e(TAG, "updateReminder failed", e)); - Note tempNote = new Note(); - tempNote.setId(noteId); - tempNote.setTitle(title != null ? title : ""); - tempNote.setValue(preview != null ? preview : ""); - tempNote.setReminderTime(nextTime); - tempNote.setReminderRepeat(repeat.name()); - tempNote.setReminderIntervalMinutes(0); - ReminderManager.scheduleReminder(ctx, tempNote); + try { + dataManager.clearReminder(noteId).blockingAwait(); + } catch (RuntimeException e) { + Log.e(TAG, "clearReminder failed", e); + } } + + if (nag > 0) ReminderManager.scheduleNag(ctx, noteId, nag, now); + else ReminderManager.cancelNag(ctx, noteId); } - private void cancelIntervalReminder( - Context ctx, - int noteId, - String repeatStr, - int intervalMinutes, - String title, - String preview) { - ReminderManager.cancelReminder(ctx, noteId); - NotificationManagerCompat.from(ctx).cancel(noteId); - - ReminderRepeat repeat = ReminderRepeat.from(repeatStr); - if (repeat != ReminderRepeat.NONE) { - long nextTime = ReminderManager.computeNextTime(System.currentTimeMillis(), repeat); - dataManager - .updateNoteReminderFull(noteId, nextTime, repeat.name(), intervalMinutes) - .subscribe(() -> {}, e -> Log.e(TAG, "updateReminderFull failed", e)); - Note tempNote = new Note(); - tempNote.setId(noteId); - tempNote.setTitle(title != null ? title : ""); - tempNote.setValue(preview != null ? preview : ""); - tempNote.setReminderTime(nextTime); - tempNote.setReminderRepeat(repeat.name()); - tempNote.setReminderIntervalMinutes(intervalMinutes); - ReminderManager.scheduleReminder(ctx, tempNote); - } else { - dataManager - .clearReminder(noteId) - .subscribe(() -> {}, e -> Log.e(TAG, "clearReminder failed", e)); + @Nullable + private Note loadNote(int noteId) { + try { + Note note = dataManager.getNoteForId(noteId).blockingGet(); + // A missing note comes back as an empty placeholder. + return note != null && note.getId() == noteId ? note : null; + } catch (RuntimeException e) { + Log.w(TAG, "note lookup failed", e); + return null; } } - private void showNotification( - Context ctx, - int noteId, - String title, - String preview, - String repeatStr, - int intervalMinutes) { - Intent noteIntent = new Intent(ctx, NoteActivity.class); - noteIntent.putExtra(NoteExtras.EXTRA_NEW_NOTE, false); - noteIntent.putExtra(NoteExtras.EXTRA_ID_NOTE, (long) noteId); + private void showNotification(@NonNull Context ctx, @NonNull Note note, int intervalMinutes) { + int noteId = note.getId(); + String title = note.getTitle(); + String preview = note.getValuePreview(); + boolean hasAttachments = note.isAttachments(); + Intent noteIntent = + NoteNavigator.existingNoteIntent( + ctx, themePreferencesCache, noteId, hasAttachments); PendingIntent openPi = TaskStackBuilder.create(ctx) @@ -165,10 +178,7 @@ private void showNotification( Intent tapIntent = new Intent(ctx, ReminderTapActivity.class); tapIntent.putExtra(ReminderManager.EXTRA_NOTE_ID, noteId); - tapIntent.putExtra(ReminderManager.EXTRA_NOTE_TITLE, title); - tapIntent.putExtra(ReminderManager.EXTRA_NOTE_PREVIEW, preview); - tapIntent.putExtra(ReminderManager.EXTRA_NOTE_REPEAT, repeatStr); - tapIntent.putExtra(ReminderManager.EXTRA_NOTE_INTERVAL_MINUTES, intervalMinutes); + tapIntent.putExtra(NoteExtras.EXTRA_HAS_ATTACHMENTS, hasAttachments); PendingIntent tapPi = PendingIntent.getActivity( ctx, @@ -178,9 +188,6 @@ private void showNotification( Intent snoozeIntent = new Intent(ctx, SnoozeActivity.class); snoozeIntent.putExtra(ReminderManager.EXTRA_NOTE_ID, noteId); - snoozeIntent.putExtra(ReminderManager.EXTRA_NOTE_TITLE, title); - snoozeIntent.putExtra(ReminderManager.EXTRA_NOTE_PREVIEW, preview); - snoozeIntent.putExtra(ReminderManager.EXTRA_NOTE_REPEAT, repeatStr); snoozeIntent.putExtra(ReminderManager.EXTRA_NOTE_INTERVAL_MINUTES, intervalMinutes); snoozeIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); PendingIntent snoozePi = @@ -193,10 +200,6 @@ private void showNotification( Intent dismissIntent = new Intent(ctx, ReminderReceiver.class); dismissIntent.setAction(ACTION_DISMISS); dismissIntent.putExtra(ReminderManager.EXTRA_NOTE_ID, noteId); - dismissIntent.putExtra(ReminderManager.EXTRA_NOTE_TITLE, title); - dismissIntent.putExtra(ReminderManager.EXTRA_NOTE_PREVIEW, preview); - dismissIntent.putExtra(ReminderManager.EXTRA_NOTE_REPEAT, repeatStr); - dismissIntent.putExtra(ReminderManager.EXTRA_NOTE_INTERVAL_MINUTES, intervalMinutes); PendingIntent dismissPi = PendingIntent.getBroadcast( ctx, @@ -211,9 +214,7 @@ private void showNotification( ? preview.substring(0, 100) : (preview != null ? preview : ""); - // For interval reminders, tapping opens the note and stops the current interval cycle - // (scheduling next daily/weekly/monthly occurrence if repeatStr != NONE). - // For one-time / periodic reminders, tapping just opens the note. + // With "repeat notification" on, tapping opens the note and also ends the cycle. PendingIntent contentPi = (intervalMinutes > 0) ? tapPi : openPi; NotificationCompat.Builder builder = diff --git a/app/src/main/java/com/pasich/mynotes/ui/state/MainViewState.java b/app/src/main/java/com/pasich/mynotes/ui/state/MainViewState.java index 8e2b45b4..11a48ab2 100644 --- a/app/src/main/java/com/pasich/mynotes/ui/state/MainViewState.java +++ b/app/src/main/java/com/pasich/mynotes/ui/state/MainViewState.java @@ -1,11 +1,20 @@ package com.pasich.mynotes.ui.state; +import androidx.annotation.Nullable; import com.pasich.mynotes.data.model.Note; import com.pasich.mynotes.data.model.Tag; +import com.pasich.mynotes.utils.recycler.diffutil.NoteDiff; import java.util.List; import java.util.Objects; -/** Immutable snapshot of the main screen UI state. */ +/** + * Immutable snapshot of the main screen UI state. + * + *

Equality is by what the screen shows, not by object identity: Room hands out new {@code Note} + * and {@code Tag} instances on every emission, so a sync that rewrites rows without changing them + * produces an equal state and is dropped before it reaches the view. The {@code uiEvent} is not + * part of equality. + */ public record MainViewState(List tags, List notes, Tag selectedTag, UiEvent uiEvent) { public static MainViewState empty() { return new MainViewState(List.of(), List.of(), null, UiEvent.NONE); @@ -16,14 +25,47 @@ public boolean equals(Object o) { if (this == o) return true; if (!(o instanceof MainViewState other)) return false; - return tags.equals(other.tags) - && notes.equals(other.notes) - && ((selectedTag == null && other.selectedTag == null) - || (selectedTag != null && selectedTag.equals(other.selectedTag))); + return sameTag(selectedTag, other.selectedTag) + && sameTags(tags, other.tags) + && sameNotes(notes, other.notes); } @Override public int hashCode() { - return Objects.hash(tags, notes, selectedTag); + int h = 1; + for (Note n : notes) h = 31 * h + n.getId(); + for (Tag t : tags) h = 31 * h + Long.hashCode(t.getId()); + if (selectedTag != null) h = 31 * h + Long.hashCode(selectedTag.getId()); + return h; + } + + private static boolean sameNotes(List a, List b) { + if (a.size() != b.size()) return false; + for (int i = 0; i < a.size(); i++) { + if (!NoteDiff.sameContent(a.get(i), b.get(i))) return false; + } + return true; + } + + private static boolean sameTags(List a, List b) { + if (a.size() != b.size()) return false; + for (int i = 0; i < a.size(); i++) { + Tag x = a.get(i); + Tag y = b.get(i); + if (!sameTag(x, y) + || x.getSelected() != y.getSelected() + || x.getVisibility() != y.getVisibility() + || x.getPosition() != y.getPosition()) { + return false; + } + } + return true; + } + + private static boolean sameTag(@Nullable Tag a, @Nullable Tag b) { + if (a == null || b == null) return a == b; + return a.getId() == b.getId() + && a.getSystemAction() == b.getSystemAction() + && Objects.equals(a.getNameTag(), b.getNameTag()); } } diff --git a/app/src/main/java/com/pasich/mynotes/ui/sync/SyncConflictPresentation.java b/app/src/main/java/com/pasich/mynotes/ui/sync/SyncConflictPresentation.java index 189d7141..924a697c 100644 --- a/app/src/main/java/com/pasich/mynotes/ui/sync/SyncConflictPresentation.java +++ b/app/src/main/java/com/pasich/mynotes/ui/sync/SyncConflictPresentation.java @@ -97,9 +97,9 @@ public static SyncConflictPresentation of(@NonNull SyncConflictEntity conflict) String loserText = loserKind == Kind.TEXT ? readable(conflict.recordType, conflict.loserJson) : ""; - int[] range = differenceRange(winnerText, loserText); - Window winnerWindow = window(winnerText, range[0], range[1]); - Window loserWindow = window(loserText, range[0], range[2]); + Comparison comparison = compare(winnerText, loserText, PREVIEW_LIMIT); + Window winnerWindow = comparison.first; + Window loserWindow = comparison.second; boolean winnerNewer = conflict.winnerUpdatedAt >= conflict.loserUpdatedAt; return new SyncConflictPresentation( @@ -195,7 +195,7 @@ static String[] labelKeys(@NonNull String recordType) { * side, where its differing part ends. Equal strings give a zero-length range. */ @NonNull - static int[] differenceRange(@NonNull String first, @NonNull String second) { + public static int[] differenceRange(@NonNull String first, @NonNull String second) { int prefix = 0; int shortest = Math.min(first.length(), second.length()); while (prefix < shortest && first.charAt(prefix) == second.charAt(prefix)) { @@ -213,11 +213,38 @@ static int[] differenceRange(@NonNull String first, @NonNull String second) { return new int[] {prefix, first.length() - suffix, second.length() - suffix}; } + /** Two texts, each windowed around the part where it differs from the other. */ + public static final class Comparison { + @NonNull public final Window first; + @NonNull public final Window second; + + Comparison(@NonNull Window first, @NonNull Window second) { + this.first = first; + this.second = second; + } + } + + /** + * Compares two texts and windows each one around the difference. + * + *

The core of a conflict's presentation, shared with anything else that shows two versions + * of a text side by side, such as a note's version history. + * + * @param limit longest text kept before it is windowed around the difference. + */ + @NonNull + public static Comparison compare(@NonNull String first, @NonNull String second, int limit) { + int[] range = differenceRange(first, second); + return new Comparison( + window(first, range[0], range[1], limit), + window(second, range[0], range[2], limit)); + } + /** Preview text plus the highlight range inside it. */ - static final class Window { - @NonNull final String text; - final int start; - final int end; + public static final class Window { + @NonNull public final String text; + public final int start; + public final int end; Window(@NonNull String text, int start, int end) { this.text = text; @@ -234,15 +261,20 @@ static final class Window { */ @NonNull static Window window(@NonNull String text, int diffStart, int diffEnd) { - if (text.length() <= PREVIEW_LIMIT) { + return window(text, diffStart, diffEnd, PREVIEW_LIMIT); + } + + @NonNull + public static Window window(@NonNull String text, int diffStart, int diffEnd, int limit) { + if (text.length() <= limit) { return new Window(text, clamp(diffStart, text.length()), clamp(diffEnd, text.length())); } int start = clamp(diffStart, text.length()); int end = clamp(diffEnd, text.length()); int centre = (start + end) / 2; - int from = Math.max(0, centre - PREVIEW_LIMIT / 2); - int to = Math.min(text.length(), from + PREVIEW_LIMIT); - from = Math.max(0, to - PREVIEW_LIMIT); + int from = Math.max(0, centre - limit / 2); + int to = Math.min(text.length(), from + limit); + from = Math.max(0, to - limit); String head = from > 0 ? "…" : ""; String tail = to < text.length() ? "…" : ""; diff --git a/app/src/main/java/com/pasich/mynotes/ui/view/activity/MainActivity.java b/app/src/main/java/com/pasich/mynotes/ui/view/activity/MainActivity.java index 8a666f53..8071ca9f 100644 --- a/app/src/main/java/com/pasich/mynotes/ui/view/activity/MainActivity.java +++ b/app/src/main/java/com/pasich/mynotes/ui/view/activity/MainActivity.java @@ -3,24 +3,32 @@ import static com.pasich.mynotes.utils.navigation.ActivityResultKeys.EXTRA_UPDATE_THEME_STYLE; import static com.pasich.mynotes.utils.navigation.ActivityResultKeys.RESULT_CODE_THEME_UPDATE; +import android.animation.ValueAnimator; import android.app.Activity; import android.content.Intent; import android.os.Bundle; +import android.os.Handler; +import android.os.Looper; +import android.provider.Settings; +import android.util.Log; +import android.view.MenuItem; import android.view.View; import android.view.animation.Animation; import android.view.animation.AnimationUtils; import androidx.activity.result.ActivityResultLauncher; import androidx.activity.result.contract.ActivityResultContracts; import androidx.annotation.NonNull; -import androidx.recyclerview.widget.DefaultItemAnimator; +import androidx.annotation.Nullable; +import androidx.lifecycle.Lifecycle; +import androidx.lifecycle.LifecycleEventObserver; import androidx.recyclerview.widget.ItemTouchHelper; import androidx.recyclerview.widget.LinearLayoutManager; import androidx.recyclerview.widget.RecyclerView; -import androidx.recyclerview.widget.StaggeredGridLayoutManager; import com.google.android.material.snackbar.Snackbar; import com.google.android.material.transition.platform.MaterialContainerTransformSharedElementCallback; import com.pasich.mynotes.R; import com.pasich.mynotes.base.activity.BaseActivity; +import com.pasich.mynotes.cache.AppPreferencesCache; import com.pasich.mynotes.cache.ThemePreferencesCache; import com.pasich.mynotes.data.model.Note; import com.pasich.mynotes.data.model.Tag; @@ -41,7 +49,7 @@ import com.pasich.mynotes.ui.view.dialogs.main.DeleteTagDialog; import com.pasich.mynotes.ui.view.dialogs.main.NameTagDialog; import com.pasich.mynotes.ui.view.dialogs.main.SingleTagSelectDialog; -import com.pasich.mynotes.ui.view.dialogs.main.SortDialog; +import com.pasich.mynotes.ui.view.dialogs.main.ViewOptionsDialog; import com.pasich.mynotes.ui.view.dialogs.main.popupWindowsTag.PopupWindowsTag; import com.pasich.mynotes.ui.view.dialogs.main.popupWindowsTag.PopupWindowsTagOnClickListener; import com.pasich.mynotes.utils.UpdateChecker; @@ -52,12 +60,26 @@ import com.pasich.mynotes.utils.adapters.tagAdapter.TagsAdapter; import com.pasich.mynotes.utils.constants.NameTransition; import com.pasich.mynotes.utils.constants.SnackBarInfo; +import com.pasich.mynotes.utils.constants.settings.SortParam; +import com.pasich.mynotes.utils.encly.EnclyMigrationRepository; import com.pasich.mynotes.utils.managers.SystemTagsManager; import com.pasich.mynotes.utils.navigation.NoteNavigator; +import com.pasich.mynotes.utils.recycler.NoteDragCallback; +import com.pasich.mynotes.utils.recycler.NoteListTransition; +import com.pasich.mynotes.utils.recycler.NotesGridLayoutManager; +import com.pasich.mynotes.utils.recycler.NotesItemAnimator; import com.pasich.mynotes.utils.recycler.SpacesItemDecoration; -import com.pasich.mynotes.utils.recycler.SwipeToListNotesCallback; +import com.pasich.mynotes.utils.reminder.ReminderManager; +import com.pasich.mynotes.utils.reminder.ReminderRescheduler; +import com.pasich.mynotes.utils.search.SearchHintFitter; +import com.pasich.mynotes.utils.search.SearchHit; import com.pasich.mynotes.utils.tool.FormatListTool; import dagger.hilt.android.AndroidEntryPoint; +import io.reactivex.Completable; +import io.reactivex.Single; +import io.reactivex.android.schedulers.AndroidSchedulers; +import io.reactivex.disposables.Disposable; +import io.reactivex.schedulers.Schedulers; import java.util.ArrayList; import java.util.List; import javax.inject.Inject; @@ -65,7 +87,8 @@ /** Main screen showing the notes list and tag navigation. */ @AndroidEntryPoint -public class MainActivity extends BaseActivity implements MainContract.view { +public class MainActivity extends BaseActivity + implements MainContract.view, ViewOptionsDialog.Listener { private final ActivityResultLauncher themeUpdateListener = registerForActivityResult( @@ -83,7 +106,7 @@ public class MainActivity extends BaseActivity implements MainContract.view { @Inject public MainContract.presenter mainPresenter; @Inject public FormatListTool formatList; @Inject public TagsAdapter tagsAdapter; - @Inject public StaggeredGridLayoutManager gridLayoutManager; + @Inject public NotesGridLayoutManager gridLayoutManager; @Inject public NoteAdapter mNoteAdapter; @@ -99,6 +122,14 @@ public class MainActivity extends BaseActivity implements MainContract.view { @Inject SearchNotesAdapter searchNotesAdapter; @Inject UpdateChecker updateChecker; @Inject ThemePreferencesCache themePreferencesCache; + @Inject EnclyMigrationRepository enclyMigrationRepository; + @Inject AppPreferencesCache appPreferencesCache; + @Inject ReminderRescheduler reminderRescheduler; + + /** The exact-alarm hint is offered once per app process, not on every rotation. */ + private static boolean exactAlarmHintOffered; + + private Disposable reminderCheck; private SearchController searchController; private AppUpdateController appUpdateController; private NavigationController navigationController; @@ -112,12 +143,93 @@ public class MainActivity extends BaseActivity implements MainContract.view { navigationController.updateNewVersionIndicator(hasNewVersion); } }); + + /** Version history opened from a note's menu; a restore is confirmed here. */ + private final ActivityResultLauncher versionHistoryLauncher = + registerForActivityResult( + new ActivityResultContracts.StartActivityForResult(), + result -> { + if (result.getResultCode() != Activity.RESULT_OK + || mActivityBinding == null) return; + Snackbar snackbar = + Snackbar.make( + mActivityBinding.drawerLayout, + R.string.version_restored, + Snackbar.LENGTH_SHORT); + snackbar.setAnchorView(mActivityBinding.newNotesButton); + snackbar.show(); + }); + + /** How long a return transition from the editor may take (300 ms) plus a margin. */ + private static final long RETURN_SETTLE_MS = 380; + + private static final String TAG = "MainActivity"; + private static final int EXACT_ALARM_HINT_MS = 8000; + + private final Handler settleHandler = new Handler(Looper.getMainLooper()); private MainRenderListsController mainRenderListsController; + + /** Latest state not applied yet (selection mode, or settling after a return). */ + private MainViewState pendingState; + + private boolean stopped; + private boolean settling; + private final Runnable endSettling = + () -> { + settling = false; + applyPendingState(); + restoreTopAfterReturn(); + }; + + /** + * The list was at its top when the screen was left. Coming back it is put at the top again, + * whatever the changes made meanwhile did to the scroll position. + */ + private boolean returnToTop; + private Tag currentSelectedTag = null; private List currentTags = new ArrayList<>(); private SelectionController selectionController; + /** Drives swipes on the list and, in the custom order, drags. */ + private ItemTouchHelper noteTouchHelper; + + /** From the moment a card is picked up until the dropped order is behind the adapter. */ + private boolean dragging; + + /** + * What the list swap in progress puts on screen once the list has faded out: the newest notes + * (null when only the layout changes) with their category, and whether the list then starts at + * the top. A swap requested while another is still fading replaces the pending callback, so + * everything it has to apply is kept here and read at that moment instead of being captured by + * each request; a later state or layout change then adds to the swap rather than dropping it. + */ + @Nullable private List swapNotes; + + @Nullable private Tag swapTag; + private boolean swapToTop; + + /** Whether the card picked up has actually moved. */ + private boolean dragMoved; + + /** The note of the card being dragged, -1 when none. */ + private int draggedNoteId = -1; + + /** Set by {@code onSelectedChanged} so a long press can tell that its drag started. */ + private boolean dragStarted; + + /** + * The note whose menu opens if the card picked up by a long press is let go without moving: in + * the custom order, press and hold both picks a card up and opens its menu. + */ + @Nullable private Note pendingMenuNote; + + private int pendingMenuPosition; + + /** The cards' "move up" and "move down" accessibility actions in the custom order. */ + private final NoteAdapter.ReorderListener accessibilityReorder = this::moveByAccessibility; + @Override public void onCreate(Bundle savedInstanceState) { setExitSharedElementCallback(new MaterialContainerTransformSharedElementCallback()); @@ -131,6 +243,7 @@ public void onCreate(Bundle savedInstanceState) { selectionController = new SelectionController(mNoteAdapter, mActivityBinding.getRoot()); mNoteAdapter.setSelectionController(selectionController); selectionController.setPanelMode(SelectionController.Mode.NORMAL); + mainRenderListsController = new MainRenderListsController(mActivityBinding); mainPresenter.attachView(this); mainPresenter.viewIsReady(); @@ -168,6 +281,11 @@ public void onTagFilterChanged(String tagName) { mainPresenter.updateSearchTagFilter(tagName); } }); + if (savedInstanceState == null) { + sweepEnclyHandoffFiles(); + rescheduleReminders(); + } + appUpdateController = new AppUpdateController(this, updateChecker, changelogLauncher); appUpdateController.showChangelogIfNeeded(); @@ -180,67 +298,192 @@ public void onTagFilterChanged(String tagName) { this::finishActivity); navigationController.init(); navigationController.handleShortcuts(getIntent()); + } - mainRenderListsController = new MainRenderListsController(mActivityBinding); + /** + * Re-arms reminders at app start, which also restores those lost to a force stop, and asks once + * for "Alarms & reminders" when upcoming reminders can only be armed to ring late. + */ + private void rescheduleReminders() { + reminderCheck = + Single.fromCallable( + () -> { + long now = System.currentTimeMillis(); + reminderRescheduler.rescheduleAll(now); + return reminderRescheduler.countUpcoming(now); + }) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + upcoming -> { + if (upcoming > 0 + && !exactAlarmHintOffered + && !ReminderManager.canScheduleExact(this)) { + exactAlarmHintOffered = true; + showExactAlarmHint(); + } + }, + e -> Log.w(TAG, "rescheduling reminders failed", e)); + } + + private void showExactAlarmHint() { + Intent settings = ReminderManager.exactAlarmSettingsIntent(this); + if (settings == null || isFinishing() || mActivityBinding == null) return; + Snackbar snackbar = + Snackbar.make( + mActivityBinding.drawerLayout, + R.string.reminder_exact_alarm_needed, + EXACT_ALARM_HINT_MS); + snackbar.setAction( + R.string.reminder_exact_alarm_allow, + v -> { + try { + startActivity(settings); + } catch (android.content.ActivityNotFoundException e) { + Log.w(TAG, "no exact alarm settings screen", e); + } + }); + snackbar.setAnchorView(mActivityBinding.newNotesButton); + snackbar.show(); + } + + /** + * A hand-off to Encly is a plain-text copy of every note. It is deleted when Encly answers; + * this sweeps one left behind when the process died before that. + */ + private void sweepEnclyHandoffFiles() { + Completable.fromAction(enclyMigrationRepository::clearArchives) + .subscribeOn(Schedulers.io()) + .onErrorComplete() + .subscribe(); } + /** + * Receives every new state. It is applied at once unless the user is selecting notes (the + * selection is kept in step with the new list and the state waits for selection to end) or the + * screen has just come back from the editor (the state waits for the return transition). + */ @Override public void render(MainViewState state) { + pendingState = state; if (selectionController.isInSelectionMode()) { + List ids = new ArrayList<>(state.notes().size()); + for (Note n : state.notes()) ids.add(n.getId()); + // May end selection mode, which applies the pending state. + selectionController.retainOnly(ids); return; } + applyPendingState(); + } + + private void applyPendingState() { + MainViewState state = pendingState; + if (state == null || settling || dragging || selectionController.isInSelectionMode()) + return; + pendingState = null; + currentSelectedTag = state.selectedTag(); currentTags = state.tags(); - // render notes list renderNotes(state.notes(), state.selectedTag(), state.uiEvent()); - // render tags list renderTags(state.tags()); } + /** Item and visibility animations only run while the user is actually looking at the list. */ + private boolean isListInteractive() { + return !stopped && !settling; + } + private void renderTags(List tags) { - mainRenderListsController.renderListTags(tags); + mainRenderListsController.renderListTags(tags, isListInteractive()); tagsAdapter.submitList(tags); } - private void renderNotes(List notes, Tag currentSelectedTag, UiEvent event) { - switch (event) { - case SORT_CHANGED, TAG_CHANGED -> - mNoteAdapter.submitList( - new ArrayList<>(notes), - () -> { - RecyclerView.ItemAnimator animator = - mActivityBinding.listNotes.getItemAnimator(); - if (animator != null) animator.endAnimations(); - mainRenderListsController.showStateNoteList( - currentSelectedTag, notes.size()); - gridLayoutManager.invalidateSpanAssignments(); - mainRenderListsController.animateNoteListChange(); - }); - default -> { - boolean shouldScrollUp = event == UiEvent.NOTE_CREATED; - List currentList = mNoteAdapter.getCurrentList(); - int previousTopId = currentList.isEmpty() ? -1 : currentList.get(0).getId(); - mNoteAdapter.submitList( - new ArrayList<>(notes), - () -> { - RecyclerView.ItemAnimator animator = - mActivityBinding.listNotes.getItemAnimator(); - if (animator != null) animator.endAnimations(); - mainRenderListsController.showStateNoteList( - currentSelectedTag, notes.size()); - gridLayoutManager.invalidateSpanAssignments(); - if (shouldScrollUp) { - mainRenderListsController.scrollUpNoteList(); - } else if (!notes.isEmpty() && notes.get(0).getId() != previousTopId) { - mainRenderListsController.scrollToTopInstant(); - } - }); - } + private void renderNotes(List notes, Tag selectedTag, UiEvent event) { + boolean animate = isListInteractive(); + List previous = mNoteAdapter.getCurrentList(); + int previousTopId = previous.isEmpty() ? -1 : previous.get(0).getId(); + boolean topChanged = !notes.isEmpty() && notes.get(0).getId() != previousTopId; + boolean datasetChanged = event == UiEvent.SORT_CHANGED || event == UiEvent.TAG_CHANGED; + // A state that arrives while a swap is still fading joins it: submitted on its own, it + // would race the swap's pending list and the older one could land last. + boolean swap = + datasetChanged + || mainRenderListsController.isSwapping() + || NoteListTransition.needsCrossfade( + previous, notes, gridLayoutManager.getSpanCount()); + List next = new ArrayList<>(notes); + int count = notes.size(); + boolean keepTop = returnToTop; + + if (swap) { + swapNotes = next; + swapTag = selectedTag; + swapToTop |= + datasetChanged || topChanged || returnToTop || event == UiEvent.NOTE_CREATED; + mainRenderListsController.swapListContent(animate, this::commitListSwap); + } else { + mNoteAdapter.submitList( + next, + () -> { + mainRenderListsController.showStateNoteList(selectedTag, count, animate); + // A new note jumps rather than scrolls: the editor opens over the list + // right away and a smooth scroll stopped half-way left it there. + if (topChanged || keepTop || event == UiEvent.NOTE_CREATED) { + mainRenderListsController.jumpToTop(); + } + }); } + mNoteAdapter.setReorderListener( + mainPresenter.isCustomOrder() ? accessibilityReorder : null); mainPresenter.clearUiEvent(); } + @Override + protected void onStart() { + super.onStart(); + if (stopped) { + stopped = false; + // Coming back, usually from the editor with a shared-element return transition. + // Moving cards under it makes the returning note land on a neighbour, so new states + // wait until the transition is over. + long settleMs = (long) (RETURN_SETTLE_MS * animatorDurationScale()); + if (settleMs > 0) { + settling = true; + settleHandler.removeCallbacks(endSettling); + settleHandler.postDelayed(endSettling, settleMs); + } else { + restoreTopAfterReturn(); + } + } + } + + private void restoreTopAfterReturn() { + if (!returnToTop) return; + returnToTop = false; + if (mActivityBinding.listNotes.getScrollState() == RecyclerView.SCROLL_STATE_DRAGGING) + return; + mainRenderListsController.jumpToTop(); + } + + @Override + protected void onStop() { + super.onStop(); + stopped = true; + returnToTop = mNoteAdapter.getItemCount() > 0 && mainRenderListsController.isAtTop(); + if (navigationController != null) navigationController.onHostStopped(); + settleHandler.removeCallbacks(endSettling); + settling = false; + // Anything held back is applied now, without animation, while nobody is looking. + applyPendingState(); + } + + private float animatorDurationScale() { + if (!ValueAnimator.areAnimatorsEnabled()) return 0f; + return Settings.Global.getFloat( + getContentResolver(), Settings.Global.ANIMATOR_DURATION_SCALE, 1f); + } + @Override public void renderDrawerStats(StatsData stats) { @@ -297,18 +540,16 @@ public void onCancel() { } @Override - public void renderSearch(List filtered) { - searchNotesAdapter.submitList(filtered); - - String q = mActivityBinding.searchView.getEditText().getText().toString().trim(); - boolean shortQuery = q.length() < 2; - boolean hasResults = !filtered.isEmpty(); + public void renderSearch(List hits, boolean titlesOnly) { + searchNotesAdapter.submitList(hits); + boolean hasResults = !hits.isEmpty(); mActivityBinding.resultsSearchList.setVisibility(hasResults ? View.VISIBLE : View.GONE); mActivityBinding.searchEmptyState.setVisibility(hasResults ? View.GONE : View.VISIBLE); if (!hasResults) { + // One character only searches titles, so "nothing found" would be premature. mActivityBinding.searchEmptyText.setText( - shortQuery + titlesOnly ? R.string.search_hint_keep_typing : R.string.search_empty_no_results); } @@ -318,19 +559,17 @@ public void renderSearch(List filtered) { public void initListeners() { mActivityBinding.actionSearch.setOnClickListener(v -> mActivityBinding.searchView.show()); searchNotesAdapter.setItemClickListener(this::openNoteEdit); + bindViewOptionsAction(formatList.getFormat()); + SearchHintFitter.attach( + mActivityBinding.actionSearch, + getString(R.string.search), + getString(R.string.search_short)); mActivityBinding.actionSearch.setOnMenuItemClickListener( menuItem -> { - int idItem = menuItem.getItemId(); - if (idItem == R.id.sort) { - if (!selectionController.isInSelectionMode()) showSortDialog(); - } else if (idItem == R.id.format) { - if (!selectionController.isInSelectionMode()) { - formatList.formatNote(menuItem); - gridLayoutManager.setSpanCount( - mainPresenter.getDataManager().getFormatCount()); - } + if (menuItem.getItemId() == R.id.viewOptions + && !selectionController.isInSelectionMode()) { + showViewOptions(); } - return true; }); @@ -372,8 +611,9 @@ public void onClick(int position, Note model) { @Override public void onLongClick(int position, Note model) { - if (!selectionController.isInSelectionMode()) - choiceNoteDialog(model, position); + if (selectionController.isInSelectionMode()) return; + if (pickUpForDrag(position, model)) return; + choiceNoteDialog(model, position); } }); selectionController.setListener( @@ -382,6 +622,8 @@ public void onLongClick(int position, Note model) { public void onSelectionModeChanged(boolean active) { mActivityBinding.newNotesButton.setVisibility( active ? View.GONE : View.VISIBLE); + // States that arrived while selecting were held back. + if (!active) applyPendingState(); } @Override @@ -410,19 +652,67 @@ private void shakeTagAt(int position) { } } - void showSortDialog() { - SortDialog dialog = SortDialog.newInstance(false); - dialog.setListener( - new SortDialog.SortListener() { - @Override - public void onSortSelected(String sortParam) { - mainPresenter.onSortChanged(sortParam); - } + private void showViewOptions() { + if (getSupportFragmentManager().findFragmentByTag(ViewOptionsDialog.TAG) != null) return; + ViewOptionsDialog.newInstance(appPreferencesCache.getSortPref(), formatList.getFormat()) + .show(getSupportFragmentManager(), ViewOptionsDialog.TAG); + } - @Override - public void onTagsSortSelected(String tagsSortParam) {} + @Override + public void onViewSortSelected(String sortParam) { + appPreferencesCache.setSortPref(sortParam); + mainPresenter.onSortChanged(sortParam); + } + + @Override + public void onViewLayoutSelected(int format) { + if (!formatList.setFormat(format, viewOptionsItem())) return; + bindViewOptionsAction(format); + // Changing the column count moves every card at once; doing it behind the list's fade + // keeps cards from sliding over one another, the same way a new sort order is shown. + mainRenderListsController.swapListContent(isListInteractive(), this::commitListSwap); + } + + /** + * Applies everything the swap in progress is waiting for while the list is not visible: the + * saved layout, then the newest notes, if any, and finally reveals the list again. + */ + private void commitListSwap() { + int format = formatList.getFormat(); + if (gridLayoutManager.getSpanCount() != format) gridLayoutManager.setSpanCount(format); + List notes = swapNotes; + swapNotes = null; + if (notes == null) { + mainRenderListsController.prepareSwappedList(mNoteAdapter.getItemCount(), false); + mainRenderListsController.showStateNoteList( + currentSelectedTag, mNoteAdapter.getItemCount(), isListInteractive()); + return; + } + Tag tag = swapTag; + mNoteAdapter.submitList( + notes, + () -> { + // Nothing is visible here: rebuild the columns from scratch so the grid + // comes back without gaps (not while empty, see prepareSwappedList). + boolean toTop = swapToTop; + swapToTop = false; + mainRenderListsController.prepareSwappedList(notes.size(), toTop); + mainRenderListsController.showStateNoteList( + tag, notes.size(), isListInteractive()); }); - dialog.show(getSupportFragmentManager(), "SortDialog"); + } + + @Nullable + private MenuItem viewOptionsItem() { + return mActivityBinding.actionSearch.getMenu().findItem(R.id.viewOptions); + } + + /** Shows the current layout on the toolbar action, for sighted and TalkBack users alike. */ + private void bindViewOptionsAction(int format) { + MenuItem item = viewOptionsItem(); + if (item == null) return; + formatList.init(item); + item.setContentDescription(getString(FormatListTool.contentDescriptionFor(format))); } @Override @@ -433,34 +723,220 @@ public void settingsLists() { mActivityBinding.listNotes.addItemDecoration(itemDecorationNotes); mActivityBinding.listNotes.setLayoutManager(gridLayoutManager); mActivityBinding.listNotes.setAdapter(mNoteAdapter); - mActivityBinding.listNotes.setItemAnimator(new DefaultItemAnimator()); + mActivityBinding.listNotes.setItemAnimator( + new NotesItemAnimator( + () -> isListInteractive() && !mainRenderListsController.isSwapping())); - new ItemTouchHelper( - new SwipeToListNotesCallback( - 0, ItemTouchHelper.LEFT | ItemTouchHelper.RIGHT) { + NoteDragCallback dragCallback = + new NoteDragCallback( + new NoteDragCallback.Host() { @Override - public boolean isItemViewSwipeEnabled() { + public boolean canDrag() { + return canDragNotes(); + } + + @Override + public boolean canSwipe() { return !selectionController.isInSelectionMode() && mainPresenter.getDataManager().getFormatCount() == 1; } + @Override + public boolean canTrade(int first, int second) { + return sameSection(first, second); + } + + @Override + public boolean isDragging() { + return dragging; + } + + @Override + public void move(int from, int to) { + mNoteAdapter.moveDuringDrag(from, to); + dragMoved = true; + pendingMenuNote = null; + } + + @Override + public void onDragStarted(@NonNull RecyclerView.ViewHolder holder) { + dragStarted = true; + dragging = true; + dragMoved = false; + mNoteAdapter.beginDrag(); + int position = holder.getBindingAdapterPosition(); + List list = mNoteAdapter.getCurrentList(); + draggedNoteId = + position >= 0 && position < list.size() + ? list.get(position).getId() + : -1; + liftCard(holder.itemView, true); + } + + @Override + public void onDragEnded(@NonNull RecyclerView.ViewHolder holder) { + liftCard(holder.itemView, false); + if (mNoteAdapter.isDragging()) dropCard(); + } + @Override public void onSwiped( @NonNull RecyclerView.ViewHolder viewHolder, int direction) { - int position = viewHolder.getBindingAdapterPosition(); - - if (direction == ItemTouchHelper.LEFT) { - selectItemAction(mNoteAdapter.getCurrentList().get(position)); - mNoteAdapter.notifyItemChanged(position); - } else { - Note sNote = mNoteAdapter.getCurrentList().get(position); - mainPresenter.setBackupDeleteNote(sNote); - mainPresenter.noteMoveToTrash(sNote); - snackBarRestoreNote(); - } + swipeNote(viewHolder, direction); } - }) - .attachToRecyclerView(mActivityBinding.listNotes); + }); + noteTouchHelper = new ItemTouchHelper(dragCallback); + dragCallback.attachHelper(noteTouchHelper); + noteTouchHelper.attachToRecyclerView(mActivityBinding.listNotes); + } + + private void swipeNote(RecyclerView.ViewHolder viewHolder, int direction) { + int position = viewHolder.getBindingAdapterPosition(); + List list = mNoteAdapter.getCurrentList(); + if (position < 0 || position >= list.size()) return; + if (direction == ItemTouchHelper.LEFT) { + // Selecting rebinds the card's selection state; the card itself is put back here. + selectItemAction(list.get(position)); + NoteDragCallback.returnSwipedCard(noteTouchHelper, viewHolder); + } else { + Note sNote = list.get(position); + mainPresenter.setBackupDeleteNote(sNote); + mainPresenter.noteMoveToTrash(sNote); + snackBarRestoreNote(); + } + } + + /** Notes can be dragged in the custom order, outside selection and search. */ + private boolean canDragNotes() { + return mainPresenter.isCustomOrder() + && !selectionController.isInSelectionMode() + && !mActivityBinding.searchView.isShowing(); + } + + /** Pinned and other notes keep their own sections; a card moves only within its own. */ + private boolean sameSection(int first, int second) { + List list = mNoteAdapter.getCurrentList(); + if (first < 0 || second < 0 || first >= list.size() || second >= list.size()) return false; + return list.get(first).isPinned() == list.get(second).isPinned(); + } + + /** + * Picks a card up in the custom order. Its menu opens instead if it is let go without moving, + * and at once for TalkBack, whose long press has no finger to follow; it moves notes with the + * cards' own actions. + * + * @return true when the card was picked up. + */ + private boolean pickUpForDrag(int position, Note note) { + // The last drop may still be settling into the adapter; this press opens the menu. + if (dragging || !canDragNotes() || isTouchExplorationEnabled()) return false; + RecyclerView.ViewHolder holder = + mActivityBinding.listNotes.findViewHolderForAdapterPosition(position); + if (holder == null) return false; + dragStarted = false; + pendingMenuNote = note; + pendingMenuPosition = position; + noteTouchHelper.startDrag(holder); + if (!dragStarted) pendingMenuNote = null; + return dragStarted; + } + + private boolean isTouchExplorationEnabled() { + android.view.accessibility.AccessibilityManager manager = + (android.view.accessibility.AccessibilityManager) + getSystemService(ACCESSIBILITY_SERVICE); + return manager != null && manager.isTouchExplorationEnabled(); + } + + /** Raises a picked-up card a little, and puts it back down. */ + private void liftCard(View card, boolean lifted) { + float scale = lifted ? 1.03f : 1f; + if (ValueAnimator.areAnimatorsEnabled()) { + card.animate().scaleX(scale).scaleY(scale).setDuration(150).start(); + } else { + card.setScaleX(scale); + card.setScaleY(scale); + } + } + + /** + * Stores where a dragged card was dropped, or opens its menu if it never moved. The card is + * found by its note in the order on screen: its view may not have a position yet when the last + * move is still to be laid out. + */ + private void dropCard() { + List order = mNoteAdapter.getCurrentList(); + int position = -1; + for (int i = 0; i < order.size(); i++) { + if (order.get(i).getId() == draggedNoteId) { + position = i; + break; + } + } + draggedNoteId = -1; + if (dragMoved && position >= 0) { + Note note = order.get(position); + mainPresenter.moveNoteInCustomOrder( + note.getId(), + neighbourId(order, position - 1, note), + neighbourId(order, position + 1, note)); + } + Note menuNote = dragMoved ? null : pendingMenuNote; + int menuPosition = pendingMenuPosition; + pendingMenuNote = null; + boolean moved = dragMoved; + boolean droppedFirst = moved && position == 0; + mNoteAdapter.endDrag( + () -> { + dragging = false; + // The cards moved one by one under the finger; laying the grid's columns out + // again closes the gaps that leaves. + if (moved) gridLayoutManager.invalidateSpanAssignments(); + // Dropped first, often after scrolling up past the top edge: show it at the + // top with the search bar and tags expanded again. + if (droppedFirst) mainRenderListsController.jumpToTop(); + // States that arrived during the drag were held back. + applyPendingState(); + }); + if (menuNote != null) choiceNoteDialog(menuNote, menuPosition); + } + + /** The id of the note at {@code position} if it is in the same section, otherwise null. */ + @Nullable + private static Integer neighbourId(List order, int position, Note note) { + if (position < 0 || position >= order.size()) return null; + Note neighbour = order.get(position); + return neighbour.isPinned() == note.isPinned() ? neighbour.getId() : null; + } + + /** "Move up" or "Move down" from TalkBack or another accessibility service. */ + private void moveByAccessibility(@NonNull Note note, int step) { + List order = mNoteAdapter.getCurrentList(); + int position = -1; + for (int i = 0; i < order.size(); i++) { + if (order.get(i).getId() == note.getId()) { + position = i; + break; + } + } + int target = position + step; + if (position < 0 || !sameSection(position, target)) { + mActivityBinding.listNotes.announceForAccessibility( + getString(step < 0 ? R.string.note_move_at_top : R.string.note_move_at_bottom)); + return; + } + Integer upper; + Integer lower; + if (step < 0) { + upper = neighbourId(order, target - 1, note); + lower = order.get(target).getId(); + } else { + upper = order.get(target).getId(); + lower = neighbourId(order, target + 1, note); + } + mainPresenter.moveNoteInCustomOrder(note.getId(), upper, lower); + mActivityBinding.listNotes.announceForAccessibility( + getString(R.string.note_moved_to, target + 1, order.size())); } public void snackBarRestoreNote() { @@ -495,6 +971,11 @@ public void callbackDeleteNote(Note mNote) { snackBarRestoreNote(); } + @Override + public void openVersionHistory(int noteId) { + versionHistoryLauncher.launch(NoteHistoryActivity.intent(this, noteId)); + } + public void openNoteEdit(Note note, View view) { new NoteNavigator(this, themePreferencesCache) .openNote(note, false, "", view, String.valueOf(note.getId())); @@ -550,6 +1031,44 @@ public void choiceNoteDialog(Note note, int position) { note.getId(), MoreNoteDialog.RootActivity.MainActivity, position); dialog.show(getSupportFragmentManager(), "ChoiceDialog"); + offerCustomOrderHint(dialog); + } + + /** + * A long press outside the custom order may have been meant to move the note: once the note's + * menu is closed, says once ever how to turn the custom order on. + */ + private void offerCustomOrderHint(MoreNoteDialog dialog) { + if (!customOrderHintDue()) return; + dialog.getLifecycle() + .addObserver( + (LifecycleEventObserver) + (source, event) -> { + if (event != Lifecycle.Event.ON_DESTROY) return; + if (!getLifecycle() + .getCurrentState() + .isAtLeast(Lifecycle.State.RESUMED) + || !customOrderHintDue()) return; + appPreferencesCache.setCustomOrderHintShown(); + Snackbar snackbar = + Snackbar.make( + mActivityBinding.drawerLayout, + R.string.custom_order_hint, + Snackbar.LENGTH_LONG); + snackbar.setAction( + R.string.custom_order_hint_action, + v -> onViewSortSelected(SortParam.Custom)); + snackbar.setAnchorView(mActivityBinding.newNotesButton); + snackbar.show(); + }); + } + + private boolean customOrderHintDue() { + return AppPreferencesCache.customOrderHintDue( + mainPresenter.isCustomOrder(), + selectionController.isInSelectionMode(), + mActivityBinding.searchView.isShowing(), + appPreferencesCache.isCustomOrderHintShown()); } private boolean finishActivity() { @@ -613,6 +1132,9 @@ public void selectItemAction(Note note) { @Override protected void onDestroy() { + settleHandler.removeCallbacksAndMessages(null); + if (reminderCheck != null) reminderCheck.dispose(); + if (mainRenderListsController != null) mainRenderListsController.release(); super.onDestroy(); if (navigationController != null) { navigationController.destroy(); diff --git a/app/src/main/java/com/pasich/mynotes/ui/view/activity/MeetEnclyActivity.java b/app/src/main/java/com/pasich/mynotes/ui/view/activity/MeetEnclyActivity.java new file mode 100644 index 00000000..83103967 --- /dev/null +++ b/app/src/main/java/com/pasich/mynotes/ui/view/activity/MeetEnclyActivity.java @@ -0,0 +1,540 @@ +package com.pasich.mynotes.ui.view.activity; + +import android.content.ActivityNotFoundException; +import android.content.ComponentName; +import android.content.Intent; +import android.content.res.ColorStateList; +import android.net.Uri; +import android.os.Bundle; +import android.util.Log; +import android.view.MenuItem; +import android.view.View; +import android.widget.TextView; +import androidx.activity.result.ActivityResult; +import androidx.activity.result.ActivityResultLauncher; +import androidx.activity.result.contract.ActivityResultContracts; +import androidx.annotation.AttrRes; +import androidx.annotation.DrawableRes; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.StringRes; +import androidx.core.content.FileProvider; +import androidx.core.view.ViewCompat; +import com.google.android.material.color.MaterialColors; +import com.google.android.material.dialog.MaterialAlertDialogBuilder; +import com.google.android.material.snackbar.Snackbar; +import com.pasich.mynotes.R; +import com.pasich.mynotes.base.activity.BaseActivity; +import com.pasich.mynotes.databinding.ActivityMeetEnclyBinding; +import com.pasich.mynotes.utils.UpdateChecker; +import com.pasich.mynotes.utils.constants.SnackBarInfo; +import com.pasich.mynotes.utils.encly.AndroidEnclyInspector; +import com.pasich.mynotes.utils.encly.EnclyHandoff; +import com.pasich.mynotes.utils.encly.EnclyMigrationRepository; +import com.pasich.mynotes.utils.encly.EnclyReceiverCheck; +import com.pasich.mynotes.utils.encly.HandoffPayloadBuilder; +import com.pasich.mynotes.utils.encly.HandoffResult; +import dagger.hilt.android.AndroidEntryPoint; +import io.reactivex.Completable; +import io.reactivex.Single; +import io.reactivex.android.schedulers.AndroidSchedulers; +import io.reactivex.disposables.CompositeDisposable; +import io.reactivex.schedulers.Schedulers; +import java.io.File; +import java.text.NumberFormat; +import java.util.Objects; +import javax.inject.Inject; + +/** + * "Meet Encly": introduces the successor app and hands the whole library over to it, one way + * (pasichDev/MyNotes#167). Nothing is deleted here unless the user asks for it as a separate, + * confirmed step after Encly reported a successful import. + */ +@AndroidEntryPoint +public class MeetEnclyActivity extends BaseActivity { + + private static final String TAG = "MeetEnclyActivity"; + private static final String STATE_HAS_RESULT = "encly_has_result"; + private static final String STATE_RESULT_CODE = "encly_result_code"; + private static final String STATE_NOTES = "encly_notes"; + private static final String STATE_TASKS = "encly_tasks"; + private static final String STATE_TAGS = "encly_tags"; + private static final String STATE_SKIPPED = "encly_skipped"; + private static final String STATE_REASON = "encly_reason"; + private static final String STATE_CLEARED = "encly_cleared"; + + @Inject EnclyMigrationRepository repository; + @Inject UpdateChecker updateChecker; + + private final CompositeDisposable disposables = new CompositeDisposable(); + private ActivityMeetEnclyBinding binding; + + @Nullable private EnclyReceiverCheck.Result receiver; + @Nullable private HandoffPayloadBuilder.Summary summary; + private int categories; + private boolean busy; + + @Nullable private HandoffResult result; + private int resultCode; + private int[] resultCounts = new int[4]; + @Nullable private String resultReason; + private boolean cleared; + + private final ActivityResultLauncher enclyLauncher = + registerForActivityResult( + new ActivityResultContracts.StartActivityForResult(), this::onEnclyResult); + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + selectTheme(); + binding = ActivityMeetEnclyBinding.inflate(getLayoutInflater()); + setupEdgeToEdgeInsets(binding.getRoot()); + setContentView(binding.getRoot()); + setSupportActionBar(binding.toolbar); + Objects.requireNonNull(getSupportActionBar()).setDisplayHomeAsUpEnabled(true); + // The page's own headline names it; the bar only carries Back. + getSupportActionBar().setDisplayShowTitleEnabled(false); + + // Whoever reached this page has met Encly: the one-time introduction sheet is not due. + updateChecker.markMeetEnclyShown(); + if (savedInstanceState == null) { + // A hand-off file left behind by a crash or a killed process is plain text: sweep it. + clearArchivesInBackground(); + } else { + restoreResult(savedInstanceState); + } + initListeners(); + loadSummary(); + } + + @Override + protected void onResume() { + super.onResume(); + // The user may have just installed or updated Encly from the store. + refreshReceiver(); + render(); + } + + @Override + public void initListeners() { + binding.primaryButton.setOnClickListener(v -> onPrimaryClicked()); + binding.clearButton.setOnClickListener(v -> confirmClear()); + } + + private void refreshReceiver() { + receiver = EnclyReceiverCheck.check(new AndroidEnclyInspector(this, probeUri())); + } + + @NonNull + private Uri probeUri() { + return Uri.parse("content://" + authority() + "/encly_handoff/handoff.zip"); + } + + @NonNull + private String authority() { + return getPackageName() + ".provider"; + } + + private void loadSummary() { + disposables.add( + Single.fromCallable(repository::load) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + library -> { + summary = library.summary(); + categories = library.categories.size(); + render(); + }, + error -> Log.e(TAG, "load summary", error))); + } + + private void render() { + if (binding == null) return; + if (summary != null) renderSummary(summary); + + EnclyReceiverCheck.Status status = + receiver == null ? EnclyReceiverCheck.Status.NOT_INSTALLED : receiver.status; + switch (status) { + case READY -> { + binding.primaryButton.setText(R.string.meet_encly_move); + binding.statusCard.setVisibility(View.GONE); + showHint(R.string.meet_encly_hint_ready); + } + case NEEDS_UPDATE -> { + binding.primaryButton.setText(R.string.meet_encly_update); + showStatus( + R.string.meet_encly_needs_update, + R.drawable.ic_history, + com.google.android.material.R.attr.colorSecondaryContainer, + com.google.android.material.R.attr.colorOnSecondaryContainer); + showHint(0); + } + case UNTRUSTED -> { + binding.primaryButton.setText(R.string.meet_encly_get); + showStatus( + R.string.meet_encly_untrusted, + R.drawable.ic_info, + com.google.android.material.R.attr.colorErrorContainer, + com.google.android.material.R.attr.colorOnErrorContainer); + showHint(0); + } + default -> { + binding.primaryButton.setText(R.string.meet_encly_get); + binding.statusCard.setVisibility(View.GONE); + showHint(R.string.meet_encly_hint_get); + } + } + // The hand-off is being prepared (a clear runs only once a result is on screen). + if (busy && result == null) { + binding.primaryButton.setText(R.string.meet_encly_moving); + showHint(0); + } + binding.primaryButton.setEnabled(!busy); + binding.progress.setVisibility(busy ? View.VISIBLE : View.GONE); + renderResult(); + } + + private void renderSummary(@NonNull HandoffPayloadBuilder.Summary summary) { + // What moves: notes always, the rest only when there is something to move. + setCount(binding.rowNotes, binding.countNotes, summary.notes, true); + setCount(binding.rowTasks, binding.countTasks, summary.tasks, false); + setCount(binding.rowTags, binding.countTags, summary.tags, false); + setCount(binding.rowCategories, binding.countCategories, categories, false); + // What stays: only what this library actually has. + boolean attachments = + setCount( + binding.rowAttachments, + binding.countAttachments, + summary.attachments, + false); + boolean reminders = + setCount(binding.rowReminders, binding.countReminders, summary.reminders, false); + boolean pinned = setCount(binding.rowPinned, binding.countPinned, summary.pinned, false); + binding.staysNone.setVisibility( + attachments || reminders || pinned ? View.GONE : View.VISIBLE); + } + + /** Shows a label-and-count row, read by TalkBack as one item. Returns whether it is shown. */ + private boolean setCount( + @NonNull View row, @NonNull TextView count, int value, boolean alwaysShown) { + boolean shown = alwaysShown || value > 0; + row.setVisibility(shown ? View.VISIBLE : View.GONE); + count.setText(NumberFormat.getIntegerInstance().format(value)); + ViewCompat.setScreenReaderFocusable(row, true); + return shown; + } + + private void showStatus( + @StringRes int text, + @DrawableRes int icon, + @AttrRes int container, + @AttrRes int onContainer) { + int background = MaterialColors.getColor(binding.statusCard, container); + int foreground = MaterialColors.getColor(binding.statusCard, onContainer); + binding.statusCard.setBackgroundTintList(ColorStateList.valueOf(background)); + binding.statusIcon.setImageResource(icon); + binding.statusIcon.setImageTintList(ColorStateList.valueOf(foreground)); + binding.statusText.setTextColor(foreground); + binding.statusText.setText(text); + binding.statusCard.setVisibility(View.VISIBLE); + } + + private void showHint(@StringRes int text) { + if (text == 0) { + binding.hintText.setVisibility(View.GONE); + } else { + binding.hintText.setText(text); + binding.hintText.setVisibility(View.VISIBLE); + } + } + + private void renderResult() { + if (result == null) { + binding.resultCard.setVisibility(View.GONE); + return; + } + binding.resultCard.setVisibility(View.VISIBLE); + if (result.success) { + setResultIcon( + R.drawable.ic_check, + com.google.android.material.R.attr.colorPrimaryContainer, + com.google.android.material.R.attr.colorOnPrimaryContainer); + binding.resultTitle.setText(R.string.meet_encly_result_ok); + binding.resultCounts.setVisibility(View.VISIBLE); + setCount(binding.resultRowNotes, binding.resultNotes, result.notes, true); + setCount(binding.resultRowTasks, binding.resultTasks, result.tasks, true); + setCount(binding.resultRowTags, binding.resultTags, result.tags, true); + setCount(binding.resultRowSkipped, binding.resultSkipped, result.skipped, false); + if (summary != null && summary.attachments > 0) { + binding.resultText.setText( + getString(R.string.meet_encly_result_attachments, summary.attachments)); + binding.resultText.setVisibility(View.VISIBLE); + } else { + binding.resultText.setVisibility(View.GONE); + } + binding.clearGroup.setVisibility(cleared ? View.GONE : View.VISIBLE); + binding.clearButton.setEnabled(!busy); + } else { + setResultIcon( + R.drawable.ic_info, + com.google.android.material.R.attr.colorErrorContainer, + com.google.android.material.R.attr.colorOnErrorContainer); + binding.resultTitle.setText(R.string.meet_encly_result_failed_title); + binding.resultCounts.setVisibility(View.GONE); + binding.resultText.setText(failureMessage(result.reason)); + binding.resultText.setVisibility(View.VISIBLE); + binding.clearGroup.setVisibility(View.GONE); + } + } + + private void setResultIcon( + @DrawableRes int icon, @AttrRes int container, @AttrRes int onContainer) { + binding.resultIcon.setImageResource(icon); + binding.resultIcon.setBackgroundTintList( + ColorStateList.valueOf(MaterialColors.getColor(binding.resultIcon, container))); + binding.resultIcon.setImageTintList( + ColorStateList.valueOf(MaterialColors.getColor(binding.resultIcon, onContainer))); + } + + private int failureMessage(@Nullable HandoffResult.Reason reason) { + if (reason == null) return R.string.meet_encly_result_cancelled; + return switch (reason) { + case CANCELLED -> R.string.meet_encly_result_cancelled; + case UNTRUSTED_CALLER -> R.string.meet_encly_result_untrusted; + case UNSUPPORTED_SCHEMA -> R.string.meet_encly_result_unsupported; + case INVALID_PAYLOAD -> R.string.meet_encly_result_invalid; + case TOO_LARGE -> R.string.meet_encly_result_too_large; + case FAILED -> R.string.meet_encly_result_failed; + }; + } + + private void onPrimaryClicked() { + if (busy) return; + refreshReceiver(); + if (receiver != null && receiver.status == EnclyReceiverCheck.Status.READY) { + startHandoff(Objects.requireNonNull(receiver.activityClassName)); + } else { + render(); + openStore(); + } + } + + private void openStore() { + try { + startActivity(new Intent(Intent.ACTION_VIEW, Uri.parse(EnclyHandoff.MARKET_URI))); + } catch (ActivityNotFoundException noStore) { + try { + startActivity( + new Intent(Intent.ACTION_VIEW, Uri.parse(EnclyHandoff.WEB_STORE_URI))); + } catch (ActivityNotFoundException noBrowser) { + Log.w(TAG, "No app can open the store page", noBrowser); + } + } + } + + private void startHandoff(@NonNull String activityClassName) { + busy = true; + result = null; + render(); + disposables.add( + Single.fromCallable( + () -> { + repository.clearArchives(); + return repository.writeArchive(repository.load()); + }) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + file -> launchEncly(file, activityClassName), + error -> { + Log.e(TAG, "prepare hand-off", error); + clearArchivesInBackground(); + busy = false; + render(); + onInfoSnack( + R.string.meet_encly_error_prepare, + null, + SnackBarInfo.Error, + Snackbar.LENGTH_LONG); + })); + } + + private void launchEncly(@NonNull File file, @NonNull String activityClassName) { + try { + Uri uri = FileProvider.getUriForFile(this, authority(), file); + Intent intent = + new Intent(EnclyHandoff.ACTION_IMPORT) + .setComponent( + new ComponentName( + EnclyHandoff.ENCLY_PACKAGE, activityClassName)) + .setDataAndType(uri, EnclyHandoff.MIME_TYPE) + .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); + // For a result: Encly identifies the sender by getCallingPackage(), which is only set + // for an activity started for a result. + enclyLauncher.launch(intent); + } catch (ActivityNotFoundException | IllegalArgumentException | SecurityException e) { + Log.e(TAG, "launch Encly", e); + clearArchivesInBackground(); + onInfoSnack( + R.string.meet_encly_error_prepare, + null, + SnackBarInfo.Error, + Snackbar.LENGTH_LONG); + } finally { + busy = false; + render(); + } + } + + private void onEnclyResult(@NonNull ActivityResult activityResult) { + // Whatever Encly answered, the plain-text copy goes now. + clearArchivesInBackground(); + Intent data = activityResult.getData(); + Bundle extras = data == null ? null : data.getExtras(); + resultCode = activityResult.getResultCode(); + resultCounts = + new int[] { + intOr(extras, EnclyHandoff.EXTRA_NOTES), + intOr(extras, EnclyHandoff.EXTRA_TASKS), + intOr(extras, EnclyHandoff.EXTRA_TAGS), + intOr(extras, EnclyHandoff.EXTRA_SKIPPED) + }; + resultReason = extras == null ? null : extras.getString(EnclyHandoff.EXTRA_REASON); + result = parse(extras); + cleared = false; + busy = false; + render(); + binding.scroll.post(() -> binding.scroll.smoothScrollTo(0, binding.resultCard.getBottom())); + } + + @NonNull + private HandoffResult parse(@Nullable Bundle extras) { + return HandoffResult.parse( + resultCode, + intExtra(extras, EnclyHandoff.EXTRA_NOTES), + intExtra(extras, EnclyHandoff.EXTRA_TASKS), + intExtra(extras, EnclyHandoff.EXTRA_TAGS), + intExtra(extras, EnclyHandoff.EXTRA_SKIPPED), + resultReason); + } + + @Nullable + private static Integer intExtra(@Nullable Bundle extras, @NonNull String key) { + if (extras == null || !extras.containsKey(key)) return null; + return extras.getInt(key); + } + + private static int intOr(@Nullable Bundle extras, @NonNull String key) { + Integer value = intExtra(extras, key); + return value == null ? -1 : value; + } + + private void confirmClear() { + new MaterialAlertDialogBuilder(this) + .setTitle(R.string.meet_encly_clear_confirm_title) + .setMessage(R.string.meet_encly_clear_confirm_message) + .setNegativeButton(R.string.cancel, (dialog, which) -> dialog.dismiss()) + .setPositiveButton( + R.string.meet_encly_clear_confirm, + (dialog, which) -> { + dialog.dismiss(); + clearMyNotes(); + }) + .show(); + } + + private void clearMyNotes() { + busy = true; + render(); + disposables.add( + Completable.fromAction(repository::clearAllData) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + () -> { + busy = false; + cleared = true; + loadSummary(); + render(); + onInfoSnack( + R.string.meet_encly_cleared, + null, + SnackBarInfo.Success, + Snackbar.LENGTH_LONG); + }, + error -> { + Log.e(TAG, "clear My Notes", error); + busy = false; + render(); + onInfoSnack( + R.string.meet_encly_clear_failed, + null, + SnackBarInfo.Error, + Snackbar.LENGTH_LONG); + })); + } + + private void clearArchivesInBackground() { + disposables.add( + Completable.fromAction(repository::clearArchives) + .subscribeOn(Schedulers.io()) + .subscribe(() -> {}, error -> Log.e(TAG, "clear hand-off files", error))); + } + + @Override + protected void onSaveInstanceState(@NonNull Bundle outState) { + super.onSaveInstanceState(outState); + outState.putBoolean(STATE_HAS_RESULT, result != null); + outState.putInt(STATE_RESULT_CODE, resultCode); + outState.putInt(STATE_NOTES, resultCounts[0]); + outState.putInt(STATE_TASKS, resultCounts[1]); + outState.putInt(STATE_TAGS, resultCounts[2]); + outState.putInt(STATE_SKIPPED, resultCounts[3]); + outState.putString(STATE_REASON, resultReason); + outState.putBoolean(STATE_CLEARED, cleared); + } + + private void restoreResult(@NonNull Bundle state) { + cleared = state.getBoolean(STATE_CLEARED, false); + if (!state.getBoolean(STATE_HAS_RESULT, false)) return; + resultCode = state.getInt(STATE_RESULT_CODE); + resultCounts = + new int[] { + state.getInt(STATE_NOTES, -1), + state.getInt(STATE_TASKS, -1), + state.getInt(STATE_TAGS, -1), + state.getInt(STATE_SKIPPED, -1) + }; + resultReason = state.getString(STATE_REASON); + result = + HandoffResult.parse( + resultCode, + orNull(resultCounts[0]), + orNull(resultCounts[1]), + orNull(resultCounts[2]), + orNull(resultCounts[3]), + resultReason); + } + + @Nullable + private static Integer orNull(int value) { + return value < 0 ? null : value; + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + if (item.getItemId() == android.R.id.home) { + finish(); + return true; + } + return super.onOptionsItemSelected(item); + } + + @Override + protected void onDestroy() { + disposables.clear(); + super.onDestroy(); + } +} diff --git a/app/src/main/java/com/pasich/mynotes/ui/view/activity/NoteHistoryActivity.java b/app/src/main/java/com/pasich/mynotes/ui/view/activity/NoteHistoryActivity.java new file mode 100644 index 00000000..209db43f --- /dev/null +++ b/app/src/main/java/com/pasich/mynotes/ui/view/activity/NoteHistoryActivity.java @@ -0,0 +1,218 @@ +package com.pasich.mynotes.ui.view.activity; + +import android.content.Context; +import android.content.Intent; +import android.os.Bundle; +import android.util.Log; +import android.view.MenuItem; +import android.view.View; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.recyclerview.widget.ConcatAdapter; +import com.google.android.material.snackbar.Snackbar; +import com.pasich.mynotes.R; +import com.pasich.mynotes.base.activity.BaseActivity; +import com.pasich.mynotes.data.DataManager; +import com.pasich.mynotes.data.database.entities.NoteVersionEntity; +import com.pasich.mynotes.data.model.Note; +import com.pasich.mynotes.databinding.ActivityNoteHistoryBinding; +import com.pasich.mynotes.ui.history.NoteVersionAdapter; +import com.pasich.mynotes.ui.view.dialogs.NoteVersionSheet; +import com.pasich.mynotes.ui.view.dialogs.RestoreVersionDialog; +import dagger.hilt.android.AndroidEntryPoint; +import io.reactivex.android.schedulers.AndroidSchedulers; +import io.reactivex.disposables.CompositeDisposable; +import io.reactivex.schedulers.Schedulers; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import javax.inject.Inject; + +/** + * A note's local version history (pasichDev/MyNotes#174): every kept version, newest first, each + * opening a preview against the current note from which it can be restored. + * + *

Restoring finishes with {@link #RESULT_OK}, so the screen that opened it — the editor, which + * then reloads the note, or the notes list — can confirm it. + */ +@AndroidEntryPoint +public class NoteHistoryActivity extends BaseActivity + implements NoteVersionSheet.Host, RestoreVersionDialog.Host { + + private static final String TAG = "NoteHistoryActivity"; + private static final String EXTRA_NOTE_ID = "noteId"; + + @Inject DataManager dataManager; + + private final CompositeDisposable disposables = new CompositeDisposable(); + private ActivityNoteHistoryBinding binding; + private NoteVersionAdapter adapter; + private int noteId; + @Nullable private Note current; + @NonNull private List versions = new ArrayList<>(); + private boolean restoring; + private boolean noteLoaded; + private boolean versionsLoaded; + + @NonNull + public static Intent intent(@NonNull Context context, int noteId) { + return new Intent(context, NoteHistoryActivity.class).putExtra(EXTRA_NOTE_ID, noteId); + } + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + selectTheme(); + binding = ActivityNoteHistoryBinding.inflate(getLayoutInflater()); + setContentView(binding.getRoot()); + setupEdgeToEdgeInsets(binding.getRoot()); + + noteId = getIntent().getIntExtra(EXTRA_NOTE_ID, 0); + setSupportActionBar(binding.toolbar); + Objects.requireNonNull(getSupportActionBar()).setDisplayHomeAsUpEnabled(true); + + adapter = new NoteVersionAdapter(this::openVersion); + binding.versionList.setAdapter(new ConcatAdapter(new NoteVersionAdapter.Header(), adapter)); + initListeners(); + loadNote(); + observeVersions(); + } + + @Override + public void initListeners() {} + + private void loadNote() { + disposables.add( + dataManager + .getNoteForId(noteId) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + note -> { + if (note.getId() == 0) { + // Deleted meanwhile, e.g. by a sync. + finish(); + return; + } + current = note; + noteLoaded = true; + String title = note.getTitle().trim(); + binding.toolbar.setSubtitle(title.isEmpty() ? null : title); + notifyOpenSheet(); + }, + error -> Log.e(TAG, "loading the note failed", error))); + } + + private void observeVersions() { + disposables.add( + dataManager + .getNoteVersions(noteId) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + list -> { + versions = list; + versionsLoaded = true; + adapter.submitList(list); + notifyOpenSheet(); + boolean empty = list.isEmpty(); + binding.versionList.setVisibility( + empty ? View.GONE : View.VISIBLE); + binding.emptyState.setVisibility( + empty ? View.VISIBLE : View.GONE); + }, + error -> Log.e(TAG, "loading the versions failed", error))); + } + + private void openVersion(@NonNull NoteVersionEntity version) { + if (current == null || restoring) return; + if (getSupportFragmentManager().findFragmentByTag(NoteVersionSheet.TAG) != null) return; + NoteVersionSheet.newInstance(version.id) + .show(getSupportFragmentManager(), NoteVersionSheet.TAG); + } + + /** A preview kept open through a recreation shows its version once the data is back. */ + private void notifyOpenSheet() { + if (getSupportFragmentManager().findFragmentByTag(NoteVersionSheet.TAG) + instanceof NoteVersionSheet sheet) { + sheet.onHostDataChanged(); + } + } + + @Override + public boolean isHistoryLoaded() { + return noteLoaded && versionsLoaded; + } + + @Nullable + @Override + public Note currentNote() { + return current; + } + + @Nullable + @Override + public NoteVersionEntity findVersion(long versionId) { + for (NoteVersionEntity version : versions) { + if (version.id == versionId) return version; + } + return null; + } + + @Override + public void onRestoreRequested(long versionId) { + if (getSupportFragmentManager().findFragmentByTag(RestoreVersionDialog.TAG) != null) return; + RestoreVersionDialog.newInstance(versionId) + .show(getSupportFragmentManager(), RestoreVersionDialog.TAG); + } + + @Override + public void onRestoreConfirmed(long versionId) { + restore(versionId); + } + + private void restore(long versionId) { + if (restoring) return; + restoring = true; + disposables.add( + dataManager + .restoreNoteVersion(noteId, versionId) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + restored -> { + restoring = false; + if (restored) { + setResult(RESULT_OK); + finish(); + } else { + showRestoreFailed(); + } + }, + error -> { + restoring = false; + Log.e(TAG, "restoring a version failed", error); + showRestoreFailed(); + })); + } + + private void showRestoreFailed() { + Snackbar.make(binding.getRoot(), R.string.version_restore_failed, Snackbar.LENGTH_LONG) + .show(); + } + + @Override + public boolean onOptionsItemSelected(@NonNull MenuItem item) { + if (item.getItemId() == android.R.id.home) { + finish(); + return true; + } + return super.onOptionsItemSelected(item); + } + + @Override + protected void onDestroy() { + disposables.dispose(); + super.onDestroy(); + } +} diff --git a/app/src/main/java/com/pasich/mynotes/ui/view/activity/ReminderTapActivity.java b/app/src/main/java/com/pasich/mynotes/ui/view/activity/ReminderTapActivity.java index 6e1f681c..a14c07a9 100644 --- a/app/src/main/java/com/pasich/mynotes/ui/view/activity/ReminderTapActivity.java +++ b/app/src/main/java/com/pasich/mynotes/ui/view/activity/ReminderTapActivity.java @@ -3,21 +3,20 @@ import android.content.Intent; import android.os.Bundle; import androidx.appcompat.app.AppCompatActivity; +import androidx.core.app.NotificationManagerCompat; import androidx.core.app.TaskStackBuilder; +import com.pasich.mynotes.cache.ThemePreferencesCache; import com.pasich.mynotes.data.DataManager; -import com.pasich.mynotes.data.model.Note; -import com.pasich.mynotes.data.model.ReminderRepeat; -import com.pasich.mynotes.ui.view.activity.noteEditor.NoteActivity; import com.pasich.mynotes.utils.navigation.NoteExtras; +import com.pasich.mynotes.utils.navigation.NoteNavigator; import com.pasich.mynotes.utils.reminder.ReminderManager; import com.pasich.mynotes.utils.reminder.TaskReminderManager; import dagger.hilt.android.AndroidEntryPoint; import javax.inject.Inject; /** - * Trampoline activity for interval reminder notification taps. Cancels the current interval alarm. - * If the note has a daily/weekly/monthly repeat, schedules the next occurrence; otherwise clears - * the reminder entirely. Then navigates to the note or tasks screen. + * Trampoline activity for taps on a reminder notification that repeats until answered. Ends the + * repeating notification, then navigates to the note or tasks screen. */ @AndroidEntryPoint public class ReminderTapActivity extends AppCompatActivity { @@ -26,6 +25,8 @@ public class ReminderTapActivity extends AppCompatActivity { @Inject DataManager dataManager; + @Inject ThemePreferencesCache themePreferencesCache; + @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); @@ -50,35 +51,17 @@ protected void onCreate(Bundle savedInstanceState) { finish(); return; } - String repeatStr = incoming.getStringExtra(ReminderManager.EXTRA_NOTE_REPEAT); - int intervalMinutes = - incoming.getIntExtra(ReminderManager.EXTRA_NOTE_INTERVAL_MINUTES, 0); - String title = incoming.getStringExtra(ReminderManager.EXTRA_NOTE_TITLE); - String preview = incoming.getStringExtra(ReminderManager.EXTRA_NOTE_PREVIEW); - - ReminderManager.cancelReminder(this, noteId); - - ReminderRepeat repeat = ReminderRepeat.from(repeatStr); - if (repeat != ReminderRepeat.NONE) { - long nextTime = ReminderManager.computeNextTime(System.currentTimeMillis(), repeat); - Note tempNote = new Note(); - tempNote.setId(noteId); - tempNote.setTitle(title != null ? title : ""); - tempNote.setValue(preview != null ? preview : ""); - tempNote.setReminderTime(nextTime); - tempNote.setReminderRepeat(repeat.name()); - tempNote.setReminderIntervalMinutes(intervalMinutes); - dataManager - .updateNoteReminderFull(noteId, nextTime, repeat.name(), intervalMinutes) - .subscribe(() -> {}, e -> {}); - ReminderManager.scheduleReminder(this, tempNote); - } else { - dataManager.clearReminder(noteId).subscribe(() -> {}, e -> {}); - } + // Tapping answers the notification: the "repeat notification" cycle ends. The + // schedule already moved to its next occurrence when the reminder fired. + ReminderManager.cancelNag(this, noteId); + NotificationManagerCompat.from(this).cancel(noteId); - Intent noteIntent = new Intent(this, NoteActivity.class); - noteIntent.putExtra(NoteExtras.EXTRA_NEW_NOTE, false); - noteIntent.putExtra(NoteExtras.EXTRA_ID_NOTE, (long) noteId); + Intent noteIntent = + NoteNavigator.existingNoteIntent( + this, + themePreferencesCache, + noteId, + incoming.getBooleanExtra(NoteExtras.EXTRA_HAS_ATTACHMENTS, false)); TaskStackBuilder.create(this) .addNextIntent(new Intent(this, MainActivity.class)) .addNextIntent(noteIntent) diff --git a/app/src/main/java/com/pasich/mynotes/ui/view/activity/SnoozeActivity.java b/app/src/main/java/com/pasich/mynotes/ui/view/activity/SnoozeActivity.java index 6ef52d6f..61a4d62e 100644 --- a/app/src/main/java/com/pasich/mynotes/ui/view/activity/SnoozeActivity.java +++ b/app/src/main/java/com/pasich/mynotes/ui/view/activity/SnoozeActivity.java @@ -6,8 +6,6 @@ import androidx.appcompat.app.AppCompatActivity; import androidx.core.app.NotificationManagerCompat; import com.google.android.material.bottomsheet.BottomSheetDialog; -import com.pasich.mynotes.data.model.Note; -import com.pasich.mynotes.data.model.ReminderRepeat; import com.pasich.mynotes.utils.reminder.ReminderManager; import dagger.hilt.android.AndroidEntryPoint; import java.util.Calendar; @@ -17,9 +15,6 @@ public class SnoozeActivity extends AppCompatActivity { private int noteId; - private String noteTitle; - private String notePreview; - private String noteRepeat; private int noteIntervalMinutes; @Override @@ -28,9 +23,6 @@ protected void onCreate(Bundle savedInstanceState) { Intent intent = getIntent(); noteId = intent.getIntExtra(ReminderManager.EXTRA_NOTE_ID, -1); - noteTitle = intent.getStringExtra(ReminderManager.EXTRA_NOTE_TITLE); - notePreview = intent.getStringExtra(ReminderManager.EXTRA_NOTE_PREVIEW); - noteRepeat = intent.getStringExtra(ReminderManager.EXTRA_NOTE_REPEAT); noteIntervalMinutes = intent.getIntExtra(ReminderManager.EXTRA_NOTE_INTERVAL_MINUTES, 0); if (noteId == -1) { @@ -85,13 +77,9 @@ private void snoozeTomorrow() { } private void scheduleSnooze(long time) { - Note tempNote = new Note(); - tempNote.setId(noteId); - tempNote.setTitle(noteTitle != null ? noteTitle : ""); - tempNote.setValue(notePreview != null ? notePreview : ""); - tempNote.setReminderTime(time); - tempNote.setReminderRepeat(noteRepeat != null ? noteRepeat : ReminderRepeat.NONE.name()); - tempNote.setReminderIntervalMinutes(noteIntervalMinutes); - ReminderManager.scheduleReminder(this, tempNote); + // The snooze replaces the "repeat notification" cycle and comes back on its own alarm, + // remembered across reboots; the repeat schedule is left as it is. + ReminderManager.cancelNag(this, noteId); + ReminderManager.scheduleSnooze(this, noteId, time, noteIntervalMinutes); } } diff --git a/app/src/main/java/com/pasich/mynotes/ui/view/activity/noteEditor/BaseNoteEditorActivity.java b/app/src/main/java/com/pasich/mynotes/ui/view/activity/noteEditor/BaseNoteEditorActivity.java index a25cfdd7..dfbb6af0 100644 --- a/app/src/main/java/com/pasich/mynotes/ui/view/activity/noteEditor/BaseNoteEditorActivity.java +++ b/app/src/main/java/com/pasich/mynotes/ui/view/activity/noteEditor/BaseNoteEditorActivity.java @@ -4,23 +4,29 @@ import static com.pasich.mynotes.utils.transition.TransitionUtil.buildContainerTransform; import android.content.Intent; -import android.content.res.ColorStateList; import android.content.res.Configuration; import android.os.Bundle; +import android.text.format.DateUtils; import android.util.Log; -import android.util.TypedValue; +import android.view.KeyEvent; import android.view.LayoutInflater; import android.view.Menu; import android.view.MenuItem; import android.view.View; import android.view.Window; import androidx.activity.OnBackPressedCallback; +import androidx.activity.result.ActivityResultLauncher; +import androidx.activity.result.contract.ActivityResultContracts; import androidx.annotation.MenuRes; import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import androidx.appcompat.widget.Toolbar; import androidx.core.view.WindowCompat; +import androidx.core.view.WindowInsetsCompat; import androidx.core.view.WindowInsetsControllerCompat; import androidx.viewbinding.ViewBinding; +import com.google.android.material.chip.Chip; +import com.google.android.material.snackbar.Snackbar; import com.google.android.material.transition.platform.MaterialContainerTransformSharedElementCallback; import com.pasich.mynotes.R; import com.pasich.mynotes.base.activity.BaseActivity; @@ -28,10 +34,13 @@ import com.pasich.mynotes.databinding.ActivityNoteExtendedEditorBinding; import com.pasich.mynotes.ui.contract.NoteContract; import com.pasich.mynotes.ui.presenter.NotePresenter; +import com.pasich.mynotes.ui.view.activity.NoteHistoryActivity; import com.pasich.mynotes.ui.view.dialogs.MoreNoteDialog; import com.pasich.mynotes.ui.view.dialogs.ReminderPickerBottomSheet; +import com.pasich.mynotes.ui.view.widgets.EditorKeyboardBar; import com.pasich.mynotes.utils.enums.SaveState; import com.pasich.mynotes.utils.navigation.NoteExtras; +import com.pasich.mynotes.utils.reminder.RepeatRuleFormatter; import com.pasich.mynotes.utils.transition.CopyNoteAnimationUtil; import java.util.Objects; import javax.inject.Inject; @@ -43,16 +52,42 @@ public abstract class BaseNoteEditorActivity extends Base // Menu for the save status indicator protected MenuItem saveStatusMenuItem; - private MenuItem reminderMenuItem; + // Undo and Redo apply while the note is being edited and are enabled while there is something + // to take back or apply again. They live on the bar above the keyboard and in More. + private boolean editing = false; + private boolean canUndo = false; + private boolean canRedo = false; + // The More sheet, while it is open, follows the history state. + @Nullable private Runnable editHistoryObserver; protected T binding; protected long idNote; + /** Version history; a restored version is read back into this editor. */ + private final ActivityResultLauncher versionHistoryLauncher = + registerForActivityResult( + new ActivityResultContracts.StartActivityForResult(), + result -> { + if (result.getResultCode() != RESULT_OK || notePresenter == null) return; + notePresenter.reloadNote(); + Snackbar.make( + binding.getRoot(), + R.string.version_restored, + Snackbar.LENGTH_SHORT) + .show(); + }); + protected abstract @MenuRes int getMenuResId(); // Returns toolbar menu layout protected abstract Toolbar getToolbar(); + /** The bar docked above the keyboard, with Undo, Redo and Hide keyboard. */ + protected abstract EditorKeyboardBar getKeyboardBar(); + + /** The chip at the top of the note that shows the active reminder. */ + protected abstract Chip getReminderChip(); + // Returns toolbar instance protected abstract T inflateBinding(LayoutInflater inflater); @@ -79,6 +114,12 @@ protected void onAfterPresenterReady() { // Set title for a new note + /** Takes back the last edit in this editor. */ + protected abstract void undoEdit(); + + /** Applies the last undone edit again. */ + protected abstract void redoEdit(); + @Override public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); @@ -92,6 +133,7 @@ public void onCreate(Bundle savedInstanceState) { setContentView(binding.getRoot()); applyEdgeToEdgeInsets(binding.getRoot()); + setupKeyboardBar(); bindingSetPresenter(binding); @@ -101,6 +143,8 @@ public void onCreate(Bundle savedInstanceState) { onAfterPresenterReady(); + getReminderChip().setOnClickListener(v -> openReminderPicker()); + getSupportFragmentManager() .setFragmentResultListener( "reminderChanged", @@ -112,7 +156,7 @@ public void onCreate(Bundle savedInstanceState) { .getNote() .setReminderTime( hasReminder ? result.getLong("reminderTime") : null); - updateReminderIcon(notePresenter.getNote()); + updateReminderChip(notePresenter.getNote()); }); getOnBackPressedDispatcher() @@ -158,30 +202,162 @@ private void setupSharedTransition(View layout, long id) { public boolean onCreateOptionsMenu(Menu menu) { getMenuInflater().inflate(getMenuResId(), menu); saveStatusMenuItem = menu.findItem(R.id.saveStatusBut); - reminderMenuItem = menu.findItem(R.id.reminderBut); return true; } + private void setupKeyboardBar() { + EditorKeyboardBar bar = getKeyboardBar(); + if (bar == null) return; + bar.setActions( + new EditorKeyboardBar.Actions() { + @Override + public void onUndo() { + undoEdit(); + } + + @Override + public void onRedo() { + redoEdit(); + } + + @Override + public void onHideKeyboard() { + hideKeyboard(); + } + }); + bar.setEditing(editing); + bar.setHistoryState(canUndo, canRedo); + } + + /** Puts the on-screen keyboard away; the note stays in editing mode. */ + protected void hideKeyboard() { + WindowCompat.getInsetsController(getWindow(), getWindow().getDecorView()) + .hide(WindowInsetsCompat.Type.ime()); + } + + /** + * The note is being edited (true) or read. Undo and Redo, on the bar above the keyboard and in + * More, and their shortcuts apply only while editing. + */ + protected void setEditing(boolean editing) { + if (this.editing == editing) return; + this.editing = editing; + EditorKeyboardBar bar = binding != null ? getKeyboardBar() : null; + if (bar != null) bar.setEditing(editing); + notifyEditHistoryObserver(); + } + + /** Enables Undo and Redo according to the editor's history. */ + protected void setUndoRedoState(boolean canUndo, boolean canRedo) { + if (this.canUndo == canUndo && this.canRedo == canRedo) return; + this.canUndo = canUndo; + this.canRedo = canRedo; + EditorKeyboardBar bar = binding != null ? getKeyboardBar() : null; + if (bar != null) bar.setHistoryState(canUndo, canRedo); + notifyEditHistoryObserver(); + } + + private void notifyEditHistoryObserver() { + if (editHistoryObserver != null) editHistoryObserver.run(); + } + + @Override + public boolean isEditingNote() { + return editing; + } + + @Override + public boolean canUndoEdit() { + return editing && canUndo; + } + + @Override + public boolean canRedoEdit() { + return editing && canRedo; + } + + @Override + public void undoLastEdit() { + if (editing) undoEdit(); + } + + @Override + public void redoLastEdit() { + if (editing) redoEdit(); + } + + @Override + public void setEditHistoryObserver(@Nullable Runnable observer) { + editHistoryObserver = observer; + } + + /** + * Ctrl+Z undoes, Ctrl+Shift+Z and Ctrl+Y redo, on a hardware keyboard. A text field has an undo + * of its own that knows nothing about this history, so an editor whose fields would see the + * shortcut first passes their key events to {@link #onHistoryShortcut} as well. + */ + @Override + public boolean onKeyShortcut(int keyCode, KeyEvent event) { + return onHistoryShortcut(keyCode, event) || super.onKeyShortcut(keyCode, event); + } + + /** Runs Undo or Redo for their shortcut; returns whether the key was one of them. */ + protected boolean onHistoryShortcut(int keyCode, KeyEvent event) { + if (event.getAction() != KeyEvent.ACTION_DOWN) return false; + if (!editing || !event.isCtrlPressed() || event.isAltPressed()) return false; + if (keyCode == KeyEvent.KEYCODE_Z) { + if (event.isShiftPressed()) redoEdit(); + else undoEdit(); + return true; + } + if (keyCode == KeyEvent.KEYCODE_Y && !event.isShiftPressed()) { + redoEdit(); + return true; + } + return false; + } + @Override public void onWindowFocusChanged(boolean hasFocus) { super.onWindowFocusChanged(hasFocus); if (hasFocus && notePresenter != null && notePresenter.hasNote()) { - updateReminderIcon(notePresenter.getNote()); + // A reminder may have fired, or been changed from the notification, meanwhile. + updateReminderChip(notePresenter.getNote()); } } - protected void updateReminderIcon(Note note) { - if (reminderMenuItem == null || note == null) return; - boolean hasReminder = note.hasReminder(); - TypedValue tv = new TypedValue(); - getTheme() - .resolveAttribute( - hasReminder - ? android.R.attr.colorPrimary - : com.google.android.material.R.attr.colorOnBackground, - tv, - true); - reminderMenuItem.setIconTintList(ColorStateList.valueOf(tv.data)); + /** + * Shows the note's upcoming reminder as a chip at the top of the note, or hides the chip when + * there is none. Setting a reminder lives in More, so the toolbar keeps only Back, the save + * status and More. + */ + protected void updateReminderChip(Note note) { + Chip chip = getReminderChip(); + if (chip == null) return; + if (note == null || !note.hasReminder()) { + chip.setVisibility(View.GONE); + return; + } + String when = + DateUtils.formatDateTime( + this, + note.getReminderTime(), + DateUtils.FORMAT_SHOW_DATE + | DateUtils.FORMAT_SHOW_TIME + | DateUtils.FORMAT_SHOW_WEEKDAY + | DateUtils.FORMAT_ABBREV_ALL); + String repeat = RepeatRuleFormatter.summary(this, note.getReminderRepeat()); + if (repeat != null) when = getString(R.string.reminder_time_with_repeat, when, repeat); + chip.setText(when); + chip.setContentDescription(getString(R.string.reminder_chip_cd, when)); + chip.setVisibility(View.VISIBLE); + } + + private void openReminderPicker() { + if (notePresenter == null || !notePresenter.hasNote()) return; + if (getSupportFragmentManager().findFragmentByTag("ReminderPicker") != null) return; + ReminderPickerBottomSheet.newInstance(notePresenter.getNote().getId()) + .show(getSupportFragmentManager(), "ReminderPicker"); } protected void settingsStatusBar(Window window) { @@ -213,13 +389,6 @@ public boolean onOptionsItemSelected(@NonNull MenuItem item) { notePresenter.closeActivity(); } - if (item.getItemId() == R.id.reminderBut) { - if (notePresenter.hasNote()) { - ReminderPickerBottomSheet.newInstance(notePresenter.getNote().getId()) - .show(getSupportFragmentManager(), "ReminderPicker"); - } - } - if (item.getItemId() == R.id.moreBut) { if (notePresenter.hasNote()) { MoreNoteDialog dialog = @@ -290,6 +459,11 @@ public void updateSaveStatus(SaveState saveState) { @Override public void onDestroy() { + if (binding != null) { + getReminderChip().setOnClickListener(null); + getKeyboardBar().setActions(null); + } + editHistoryObserver = null; super.onDestroy(); if (notePresenter != null) { ((NotePresenter) notePresenter).cleanupHandlers(); @@ -325,6 +499,16 @@ public void changeEditor(long idNote) { finish(); } + @Override + public void openVersionHistory() { + if (notePresenter == null || !notePresenter.hasNote()) return; + // Whatever the autosave still holds is written first, so the history shows it and a + // restore keeps it as the version it replaces. + notePresenter.flushPending(); + versionHistoryLauncher.launch( + NoteHistoryActivity.intent(this, notePresenter.getNote().getId())); + } + @Override public void runCopyAnimation() { CopyNoteAnimationUtil.runCopyAnimation(binding.getRoot()); diff --git a/app/src/main/java/com/pasich/mynotes/ui/view/activity/noteEditor/NoteActivity.java b/app/src/main/java/com/pasich/mynotes/ui/view/activity/noteEditor/NoteActivity.java index 7b1a818c..7a3dfb7c 100644 --- a/app/src/main/java/com/pasich/mynotes/ui/view/activity/noteEditor/NoteActivity.java +++ b/app/src/main/java/com/pasich/mynotes/ui/view/activity/noteEditor/NoteActivity.java @@ -4,29 +4,81 @@ import static com.pasich.mynotes.utils.FormattedDataUtil.lastDayEditNote; import android.content.Context; +import android.graphics.Rect; +import android.os.Bundle; +import android.os.SystemClock; import android.text.Editable; +import android.text.InputFilter; import android.text.Layout; +import android.view.GestureDetector; import android.view.LayoutInflater; +import android.view.MotionEvent; import android.view.View; +import android.view.inputmethod.BaseInputConnection; import android.view.inputmethod.InputMethodManager; +import android.widget.EditText; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import androidx.appcompat.widget.Toolbar; import androidx.coordinatorlayout.widget.CoordinatorLayout; import androidx.core.graphics.Insets; +import androidx.core.view.OneShotPreDrawListener; import androidx.core.view.ViewCompat; +import androidx.core.view.WindowCompat; import androidx.core.view.WindowInsetsCompat; import androidx.core.widget.NestedScrollView; +import com.google.android.material.chip.Chip; import com.pasich.mynotes.R; import com.pasich.mynotes.base.simplifications.TextWatcher; +import com.pasich.mynotes.cache.NoteOpeningPreferences; import com.pasich.mynotes.data.model.Note; import com.pasich.mynotes.databinding.ActivityNoteBinding; import com.pasich.mynotes.ui.presenter.NotePresenter; -import com.pasich.mynotes.utils.linkMovement.CustomLinkMovementMethod; +import com.pasich.mynotes.ui.view.widgets.EditorKeyboardBar; +import com.pasich.mynotes.utils.editor.EditableLinkMovementMethod; +import com.pasich.mynotes.utils.editor.EditorCursor; +import com.pasich.mynotes.utils.editor.NoteViewState; +import com.pasich.mynotes.utils.editor.NoteViewStateStore; +import com.pasich.mynotes.utils.editor.PositionRestorer; +import com.pasich.mynotes.utils.editor.TextEditHistory; import dagger.hilt.android.AndroidEntryPoint; +import javax.inject.Inject; /** Activity for creating and editing a single note. */ @AndroidEntryPoint public class NoteActivity extends BaseNoteEditorActivity { + private static final String STATE_EDITING = "note.editing"; + private static final String STATE_SELECTION_START = "note.selectionStart"; + private static final String STATE_SELECTION_END = "note.selectionEnd"; + private static final String STATE_ANCHOR_OFFSET = "note.anchorOffset"; + private static final String STATE_DRAFT_TITLE = "note.draftTitle"; + private static final String STATE_DRAFT_VALUE = "note.draftValue"; + private static final String STATE_HISTORY_NOTE = "note.history.noteId"; + private static final String STATE_HISTORY_FIELDS = "note.history.fields"; + private static final String STATE_HISTORY_STARTS = "note.history.starts"; + private static final String STATE_HISTORY_REMOVED = "note.history.removed"; + private static final String STATE_HISTORY_INSERTED = "note.history.inserted"; + private static final String STATE_HISTORY_SELECTIONS = "note.history.selections"; + private static final String STATE_HISTORY_UNDO_COUNT = "note.history.undoCount"; + private static final String STATE_HISTORY_TITLE_HASH = "note.history.titleHash"; + private static final String STATE_HISTORY_BODY_HASH = "note.history.bodyHash"; + + /** + * Most text of the undo history kept across a recreation. The saved state has a size limit + * shared with the whole screen; the most recent steps are what matter after a rotation. + */ + private static final int MAX_HISTORY_STATE_CHARS = 32 * 1024; + + /** + * How long typing has to pause before the body is copied out of the field. Copying, counting + * words and comparing are linear in the note's length; doing them on every keystroke made + * typing in a long note stutter. + */ + private static final long VALUE_COMMIT_DELAY_MS = 250; + + private final Runnable commitValueRunnable = this::commitValueEdit; + private TextWatcher titleWatcher; private TextWatcher valueWatcher; @@ -36,18 +88,231 @@ public class NoteActivity extends BaseNoteEditorActivity { // Tracks scroll progress when adjusting view private int scrollProgress = -1; - // Saves scroll position when keyboard opens/closes - private int savedScrollPosition = -1; - // Tracks current keyboard visibility state private boolean isKeyboardVisible = false; // Tracks last cursor line in multiline input private int lastCursorLine = -1; + // State carried over from a previous instance (rotation, process restore), applied once the + // note has been loaded. + private boolean restoredEditing = false; + private int restoredSelectionStart = EditorCursor.NONE; + private int restoredSelectionEnd = EditorCursor.NONE; + private int restoredAnchorOffset = EditorCursor.NONE; + private String draftTitle; + private String draftValue; + + // Undo and redo for the title and the body. Changes made by the app itself (loading the note, + // applying an undo) are not recorded. + private final TextEditHistory editHistory = new TextEditHistory(); + private boolean historyMuted = false; + private HistoryWatcher titleHistoryWatcher; + private HistoryWatcher valueHistoryWatcher; + // History carried over from a previous instance, installed once the same note is shown again. + @Nullable private TextEditHistory.Snapshot restoredHistory; + private long restoredHistoryNoteId = -1; + + @Inject NoteViewStateStore noteViewStateStore; + @Inject NoteOpeningPreferences noteOpeningPreferences; + + // Double tap on the text in reading mode starts editing there, when that setting is on. + private GestureDetector doubleTapDetector; + + // A fresh open (not a recreation) goes back to where the note was left. + private boolean restoreSavedPosition = false; + // Set while a saved position waits for the first layout; nothing is saved meanwhile, so + // leaving at once cannot replace the saved position with the top of the note. + private boolean savedPositionPending = false; + // The note's text is on screen; before that there is no position worth saving. + private boolean noteShown = false; + // Caret from the last visit, used when editing starts while it is still on screen. + private int savedSelectionStart = EditorCursor.NONE; + private int savedSelectionEnd = EditorCursor.NONE; + + @Override + public void onCreate(Bundle savedInstanceState) { + readRestoredState(savedInstanceState); + restoreSavedPosition = savedInstanceState == null; + super.onCreate(savedInstanceState); + } + + private void readRestoredState(@Nullable Bundle state) { + if (state == null) return; + restoredEditing = state.getBoolean(STATE_EDITING, false); + restoredSelectionStart = state.getInt(STATE_SELECTION_START, EditorCursor.NONE); + restoredSelectionEnd = state.getInt(STATE_SELECTION_END, EditorCursor.NONE); + restoredAnchorOffset = state.getInt(STATE_ANCHOR_OFFSET, EditorCursor.NONE); + draftTitle = state.getString(STATE_DRAFT_TITLE); + draftValue = state.getString(STATE_DRAFT_VALUE); + if (state.containsKey(STATE_HISTORY_FIELDS)) { + restoredHistoryNoteId = state.getLong(STATE_HISTORY_NOTE, -1); + restoredHistory = + new TextEditHistory.Snapshot( + state.getIntArray(STATE_HISTORY_FIELDS), + state.getIntArray(STATE_HISTORY_STARTS), + state.getStringArray(STATE_HISTORY_REMOVED), + state.getStringArray(STATE_HISTORY_INSERTED), + state.getIntArray(STATE_HISTORY_SELECTIONS), + state.getInt(STATE_HISTORY_UNDO_COUNT), + state.getInt(STATE_HISTORY_TITLE_HASH), + state.getInt(STATE_HISTORY_BODY_HASH)); + } + } + + /** Keeps the most recent undo steps, bounded, for the next instance of this screen. */ + private void saveEditHistory(Bundle outState) { + if (notePresenter == null || (!editHistory.canUndo() && !editHistory.canRedo())) return; + TextEditHistory.Snapshot snapshot = + editHistory.snapshot( + MAX_HISTORY_STATE_CHARS, + binding.notesTitle.getText().toString(), + binding.valueNote.getText().toString()); + if (snapshot.fields.length == 0) return; + outState.putLong(STATE_HISTORY_NOTE, notePresenter.getIdKey()); + outState.putIntArray(STATE_HISTORY_FIELDS, snapshot.fields); + outState.putIntArray(STATE_HISTORY_STARTS, snapshot.starts); + outState.putStringArray(STATE_HISTORY_REMOVED, snapshot.removed); + outState.putStringArray(STATE_HISTORY_INSERTED, snapshot.inserted); + outState.putIntArray(STATE_HISTORY_SELECTIONS, snapshot.selections); + outState.putInt(STATE_HISTORY_UNDO_COUNT, snapshot.undoCount); + outState.putInt(STATE_HISTORY_TITLE_HASH, snapshot.titleHash); + outState.putInt(STATE_HISTORY_BODY_HASH, snapshot.bodyHash); + } + + @Override + protected void onSaveInstanceState(@NonNull Bundle outState) { + super.onSaveInstanceState(outState); + if (binding == null) return; + boolean editing = binding.valueNote.isEnabled(); + outState.putBoolean(STATE_EDITING, editing); + if (editing) { + outState.putInt(STATE_SELECTION_START, binding.valueNote.getSelectionStart()); + outState.putInt(STATE_SELECTION_END, binding.valueNote.getSelectionEnd()); + } + outState.putInt(STATE_ANCHOR_OFFSET, readingAnchorOffset()); + // The fields do not save their own text (see initListeners); a draft is kept only while a + // save is still on its way, so the next instance never shows older text than was typed. + if (notePresenter != null && notePresenter.hasUnsavedChanges()) { + outState.putString(STATE_DRAFT_TITLE, binding.notesTitle.getText().toString()); + outState.putString(STATE_DRAFT_VALUE, binding.valueNote.getText().toString()); + } + saveEditHistory(outState); + } + @Override protected void onNewNoteInit(Note note) { - // Simple version + noteShown = true; + resetEditHistory(); + } + + /** + * Starts the history again for the note now shown. After a recreation the previous instance's + * history comes back, but only for the same note and only if the fields hold exactly the text + * it was saved with; anything else would undo into the wrong text. + */ + @Override + public void resetEditHistory() { + TextEditHistory.Snapshot pending = restoredHistory; + long pendingNoteId = restoredHistoryNoteId; + restoredHistory = null; + restoredHistoryNoteId = -1; + if (binding == null) return; + if (pending != null && notePresenter != null && pendingNoteId == notePresenter.getIdKey()) { + editHistory.restore( + pending, + binding.notesTitle.getText().toString(), + binding.valueNote.getText().toString()); + } else { + editHistory.clear(); + } + } + + @Override + protected void undoEdit() { + if (binding == null || !binding.valueNote.isEnabled()) return; + applyHistoryChange(editHistory.undo()); + } + + @Override + protected void redoEdit() { + if (binding == null || !binding.valueNote.isEnabled()) return; + applyHistoryChange(editHistory.redo()); + } + + /** + * Applies an undo or redo to its field as an ordinary edit, so it is autosaved like typing, and + * puts the selection back where it was at that step. + */ + private void applyHistoryChange(@Nullable TextEditHistory.Change change) { + if (change == null) return; + EditText field = + change.field == TextEditHistory.FIELD_TITLE + ? binding.notesTitle + : binding.valueNote; + Editable text = field.getText(); + int start = EditorCursor.clamp(change.start, text.length()); + int end = EditorCursor.clamp(change.start + change.replaceLength, text.length()); + // A word the keyboard is still composing would otherwise be committed over the result. + BaseInputConnection.removeComposingSpans(text); + historyMuted = true; + try { + text.replace(start, Math.max(start, end), change.text); + } finally { + historyMuted = false; + } + if (!field.hasFocus()) field.requestFocus(); + int length = field.length(); + field.setSelection( + EditorCursor.clamp(change.selectionStart, length), + EditorCursor.clamp(change.selectionEnd, length)); + // The keyboard's idea of the text and the caret is out of date now. + InputMethodManager imm = (InputMethodManager) getSystemService(INPUT_METHOD_SERVICE); + if (imm != null) imm.restartInput(field); + } + + /** Records what the user changes in one field. */ + private final class HistoryWatcher implements android.text.TextWatcher { + private final int field; + private final EditText view; + private String removed; + private int selectionStart; + private int selectionEnd; + private boolean armed; + + HistoryWatcher(int field, EditText view) { + this.field = field; + this.view = view; + } + + @Override + public void beforeTextChanged(CharSequence s, int start, int count, int after) { + armed = !historyMuted; + if (!armed) return; + removed = s.subSequence(start, start + count).toString(); + selectionStart = view.getSelectionStart(); + selectionEnd = view.getSelectionEnd(); + } + + @Override + public void onTextChanged(CharSequence s, int start, int before, int count) { + if (!armed) return; + armed = false; + editHistory.record( + field, + start, + removed, + s.subSequence(start, start + count).toString(), + selectionStart, + selectionEnd, + SystemClock.uptimeMillis()); + removed = null; + } + + @Override + public void afterTextChanged(Editable s) { + // Recorded in onTextChanged, where the change's range is known. + } } @Override @@ -65,6 +330,16 @@ protected Toolbar getToolbar() { return binding.toolbar; } + @Override + protected Chip getReminderChip() { + return binding.reminderChip; + } + + @Override + protected EditorKeyboardBar getKeyboardBar() { + return binding.keyboardBar; + } + @Override protected ActivityNoteBinding inflateBinding(LayoutInflater inflater) { return ActivityNoteBinding.inflate(inflater); @@ -78,6 +353,57 @@ protected void bindingSetPresenter(ActivityNoteBinding binding) { @Override protected void onAfterPresenterReady() { setupAppBarScrollListener(); + if (noteOpeningPreferences.isDoubleTapToEdit()) setupDoubleTapToEdit(); + } + + private void setupDoubleTapToEdit() { + doubleTapDetector = + new GestureDetector( + this, + new GestureDetector.SimpleOnGestureListener() { + @Override + public boolean onDoubleTap(@NonNull MotionEvent e) { + return editAtTouch(e.getRawX(), e.getRawY()); + } + }); + doubleTapDetector.setIsLongpressEnabled(false); + } + + /** + * The body ignores touches while reading (it is disabled), so the gesture is watched here + * without taking any event from the views underneath. + */ + @Override + public boolean dispatchTouchEvent(MotionEvent ev) { + if (doubleTapDetector != null + && binding != null + && notePresenter != null + && notePresenter.hasNote() + && !binding.valueNote.isEnabled()) { + doubleTapDetector.onTouchEvent(ev); + } + return super.dispatchTouchEvent(ev); + } + + /** Starts editing with the caret at the text under a screen point inside the body. */ + private boolean editAtTouch(float rawX, float rawY) { + int[] location = new int[2]; + binding.valueNote.getLocationOnScreen(location); + float x = rawX - location[0]; + float y = rawY - location[1]; + if (x < 0 + || y < 0 + || x > binding.valueNote.getWidth() + || y > binding.valueNote.getHeight()) { + return false; + } + int offset = binding.valueNote.getOffsetForPosition(x, y); + if (offset < 0) return false; + int caret = EditorCursor.clamp(offset, binding.valueNote.length()); + restoredSelectionStart = caret; + restoredSelectionEnd = caret; + activatedActivity(); + return true; } /** Scrolls the view to keep the cursor visible when the keyboard is open. */ @@ -113,7 +439,10 @@ private void scrollToCursor() { Insets systemInsets = insets.getInsets(WindowInsetsCompat.Type.systemBars()); int visibleHeight = - binding.getRoot().getHeight() - imeInsets.bottom - systemInsets.top; + binding.getRoot().getHeight() + - imeInsets.bottom + - systemInsets.top + - binding.keyboardBar.getReservedHeight(); int lineHeight = layout.getLineBottom(line) - layout.getLineTop(line); int lineVisibleTop = absoluteLineTop - currentScrollY; @@ -172,10 +501,11 @@ private void updateScrollProgress(int scrollY) { } /** - * Handles system indents and keyboard appearance/disappearance: - correctly sets top inset for - * root layout - raises FAB above the navbar - adapts the bottom margin of ScrollView to the - * keyboard or system navbar - saves and restores the scroll position when opening/closing the - * keyboard - automatically scrolls to the cursor when the keyboard appears + * Handles system insets and the keyboard: the root takes the status bar, the FAB clears the + * navigation bar, the bar with Undo and Redo sits on the keyboard, and the scroll view ends + * above that bar, or above the navigation bar while the keyboard is down. When the keyboard + * opens, the caret is brought into view; when it closes nothing is moved, so the text stays + * where the user left it. */ @Override protected void applyEdgeToEdgeInsets(View rootView) { @@ -196,34 +526,30 @@ protected void applyEdgeToEdgeInsets(View rootView) { binding.editActive.setLayoutParams(fab); Insets systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars()); - Insets imeInsets = insets.getInsets(WindowInsetsCompat.Type.ime()); boolean keyboardWasVisible = isKeyboardVisible; boolean keyboardWillBeVisible = insets.isVisible(WindowInsetsCompat.Type.ime()); - - if (!keyboardWasVisible && keyboardWillBeVisible) { - savedScrollPosition = binding.scrollView.getScrollY(); - } - isKeyboardVisible = keyboardWillBeVisible; v.setPadding(v.getPaddingLeft(), systemBars.top, v.getPaddingRight(), 0); - int bottomMargin = Math.max(imeInsets.bottom, systemBars.bottom); - + binding.keyboardBar.onWindowInsets(insets); + int bottomMargin = + Math.max(imeInsets.bottom, systemBars.bottom) + + binding.keyboardBar.getReservedHeight(); android.widget.LinearLayout.LayoutParams params = (android.widget.LinearLayout.LayoutParams) binding.scrollView.getLayoutParams(); - - if (keyboardWasVisible && !keyboardWillBeVisible && savedScrollPosition >= 0) { - binding.scrollView.scrollTo(0, savedScrollPosition); + if (params.bottomMargin != bottomMargin) { + params.setMargins( + params.leftMargin, + params.topMargin, + params.rightMargin, + bottomMargin); + binding.scrollView.setLayoutParams(params); } - params.setMargins( - params.leftMargin, params.topMargin, params.rightMargin, bottomMargin); - binding.scrollView.setLayoutParams(params); - binding.scrollView.setPadding( binding.scrollView.getPaddingLeft(), binding.scrollView.getPaddingTop(), @@ -236,36 +562,160 @@ protected void applyEdgeToEdgeInsets(View rootView) { && binding.valueNote.isFocused()) { lastCursorPosition = -1; binding.valueNote.postDelayed(this::scrollToCursor, 200); - } else if (keyboardWasVisible - && !keyboardWillBeVisible - && savedScrollPosition >= 0) { - binding.scrollView.post( - () -> { - if (Math.abs( - binding.scrollView.getScrollY() - - savedScrollPosition) - > 5) { - binding.scrollView.scrollTo(0, savedScrollPosition); - } - }); } return insets; }); } + /** + * Offset of the text at the top of the viewport; 0 while the start of the note is on screen. + */ + private int firstVisibleOffset() { + Layout layout = binding.valueNote.getLayout(); + if (layout == null) return EditorCursor.NONE; + int top = binding.scrollView.getScrollY() - valueTextTop(); + if (top <= 0) return 0; + return layout.getLineStart(layout.getLineForVertical(top)); + } + + /** + * Reading position to restore after a recreation, or {@link EditorCursor#NONE} while the title + * area is still on screen. Unlike a pixel scroll position it survives a rotation, where the + * text re-wraps to a different width. + */ + private int readingAnchorOffset() { + if (binding.scrollView.getScrollY() - valueTextTop() <= 0) return EditorCursor.NONE; + return firstVisibleOffset(); + } + + /** Offset of the text at the bottom of the viewport, or {@link EditorCursor#NONE}. */ + private int lastVisibleOffset() { + Layout layout = binding.valueNote.getLayout(); + if (layout == null) return EditorCursor.NONE; + int bottom = binding.scrollView.getScrollY() + binding.scrollView.getHeight(); + int y = bottom - valueTextTop(); + if (y < 0) return EditorCursor.NONE; + return layout.getLineEnd(layout.getLineForVertical(y)); + } + + /** Top of the note's first text line in the scroll view's content coordinates. */ + private int valueTextTop() { + Rect rect = new Rect(); + binding.valueNote.getDrawingRect(rect); + binding.scrollView.offsetDescendantRectToMyCoords(binding.valueNote, rect); + return rect.top + binding.valueNote.getTotalPaddingTop(); + } + + /** Scrolls so the line holding {@code offset} sits at the top, once the text is laid out. */ + private void scrollToOffsetWhenLaidOut(int offset) { + scrollToOffsetWhenLaidOut(offset, null); + } + + private void scrollToOffsetWhenLaidOut(int offset, @Nullable Runnable then) { + OneShotPreDrawListener.add( + binding.valueNote, + () -> { + Layout layout = binding.valueNote.getLayout(); + if (layout != null) { + int clamped = EditorCursor.clamp(offset, binding.valueNote.length()); + int line = layout.getLineForOffset(clamped); + binding.scrollView.scrollTo(0, valueTextTop() + layout.getLineTop(line)); + } + if (then != null) then.run(); + }); + } + + /** + * Brings back where the note was left on this device: the line at the top of the screen, and + * the caret for when editing starts. The text may have changed since; {@link PositionRestorer} + * finds the position again or falls back to the start of the note. + */ + private void restoreSavedPosition(long noteId, String shownValue, @Nullable Runnable then) { + NoteViewState saved = noteViewStateStore.get(noteId); + PositionRestorer.SimpleTarget target = + PositionRestorer.restoreSimple(saved != null ? saved.simple : null, shownValue); + if (target.hasSelection()) { + savedSelectionStart = target.selectionStart; + savedSelectionEnd = target.selectionEnd; + } + if (target.topOffset == EditorCursor.NONE) { + // Nothing to scroll to: the note opens at its start, title included. + if (then != null) OneShotPreDrawListener.add(binding.valueNote, then); + return; + } + savedPositionPending = true; + scrollToOffsetWhenLaidOut( + target.topOffset, + () -> { + savedPositionPending = false; + if (then != null) then.run(); + }); + } + + /** Starts editing as the note opens, once it is laid out so the caret lands predictably. */ + private void startEditingOnOpen() { + activatedActivity(); + // The window may not have focus yet on the first frame; the controller waits for it. + WindowCompat.getInsetsController(getWindow(), binding.valueNote) + .show(WindowInsetsCompat.Type.ime()); + } + + @Override + protected void onPause() { + super.onPause(); + saveViewState(); + } + + /** Keeps where the note is being read or edited, for the next time it is opened. */ + private void saveViewState() { + if (binding == null || notePresenter == null || !noteShown || savedPositionPending) { + return; + } + long noteId = notePresenter.getIdKey(); + // An empty note has no position, and a new one is deleted as the screen closes. + if (noteId <= 0 || binding.valueNote.length() == 0) return; + boolean editing = binding.valueNote.isEnabled(); + int scrollY = binding.scrollView.getScrollY(); + View content = binding.scrollView.getChildAt(0); + int scrollable = content != null ? content.getHeight() - binding.scrollView.getHeight() : 0; + noteViewStateStore.putSimple( + noteId, + PositionRestorer.captureSimple( + binding.valueNote.getText().toString(), + editing ? binding.valueNote.getSelectionStart() : EditorCursor.NONE, + editing ? binding.valueNote.getSelectionEnd() : EditorCursor.NONE, + readingAnchorOffset(), + scrollY, + scrollable > 0 ? (float) scrollY / scrollable : 0f)); + } + @Override public void onStop() { super.onStop(); - // CRITICAL: Emergency saving when stopping Activity - if (notePresenter != null && notePresenter.getNote() != null) { - String currentTitle = binding != null ? binding.notesTitle.getText().toString() : ""; - String currentValue = binding != null ? binding.valueNote.getText().toString() : ""; - // If there are unsaved changes, perform an emergency save. - notePresenter.simpleNoteChange(currentTitle, currentValue, true); + // Leaving the screen (home, rotation, another app) writes what is still waiting in the + // debounce, so the last seconds of typing are never lost. + if (binding == null || notePresenter == null || !notePresenter.hasNote()) return; + commitPendingValueEdit(); + notePresenter.flushPending(); + } + + /** Copies the body out of the field and hands it to the presenter if it changed. */ + private void commitValueEdit() { + if (binding == null || notePresenter == null || !notePresenter.hasNote()) return; + String newValue = binding.valueNote.getText().toString(); + updateWordCount(newValue); + if (!newValue.equals(notePresenter.getNote().getValue())) { + notePresenter.simpleNoteChange(null, newValue, false); } } + /** Runs a body commit that is still waiting for typing to pause, right now. */ + private void commitPendingValueEdit() { + binding.valueNote.removeCallbacks(commitValueRunnable); + commitValueEdit(); + } + @Override public void initListeners() { titleWatcher = @@ -273,10 +723,6 @@ public void initListeners() { @Override protected void changeText(Editable s) { if (!notePresenter.hasNote()) return; - if (s.toString().contains("\n")) { - binding.notesTitle.setText(s.toString().replace('\n', ' ').trim()); - binding.valueNote.requestFocus(); - } String title = s.toString().trim(); binding.titleToolbarCollapsed.setText( !title.isEmpty() ? title : getString(R.string.noteTitle)); @@ -284,21 +730,37 @@ protected void changeText(Editable s) { } }; binding.notesTitle.addTextChangedListener(titleWatcher); + addTitleLineBreakFilter(); valueWatcher = new TextWatcher() { @Override protected void changeText(Editable s) { - if (!notePresenter.hasNote()) return; - String newValue = s.toString(); - updateWordCount(newValue); - if (!newValue.equals(notePresenter.getNote().getValue())) { - notePresenter.simpleNoteChange(null, newValue, false); - } + // Nothing linear in the note's length runs per keystroke: the body is + // copied, counted and compared once typing pauses. + binding.valueNote.removeCallbacks(commitValueRunnable); + binding.valueNote.postDelayed(commitValueRunnable, VALUE_COMMIT_DELAY_MS); } }; binding.valueNote.addTextChangedListener(valueWatcher); + titleHistoryWatcher = new HistoryWatcher(TextEditHistory.FIELD_TITLE, binding.notesTitle); + valueHistoryWatcher = new HistoryWatcher(TextEditHistory.FIELD_BODY, binding.valueNote); + binding.notesTitle.addTextChangedListener(titleHistoryWatcher); + binding.valueNote.addTextChangedListener(valueHistoryWatcher); + editHistory.setListener(this::setUndoRedoState); + // Seen here before the fields' own undo would act on the shortcut. + binding.notesTitle.setOnKeyListener( + (v, keyCode, event) -> onHistoryShortcut(keyCode, event)); + binding.valueNote.setOnKeyListener( + (v, keyCode, event) -> onHistoryShortcut(keyCode, event)); + + // The note is reloaded from the database on recreation and an unsaved draft is kept in + // onSaveInstanceState, so the fields' own copies would only be a second, stale snapshot + // of the same text in the saved state. + binding.notesTitle.setSaveEnabled(false); + binding.valueNote.setSaveEnabled(false); + // Add a click handler for the input field - only for cursor movement processing binding.valueNote.setOnClickListener( v -> { @@ -308,14 +770,65 @@ protected void changeText(Editable s) { }); } + /** + * The title is one line. Enter moves on to the body and a pasted line break becomes a space. + * Done as the text comes in rather than by rewriting the title afterwards, so the undo history + * only ever sees the title as it is. + */ + private void addTitleLineBreakFilter() { + InputFilter lineBreaks = + (source, start, end, dest, dstart, dend) -> { + boolean hasLineBreak = false; + for (int i = start; i < end; i++) { + if (source.charAt(i) == '\n') { + hasLineBreak = true; + break; + } + } + if (!hasLineBreak) return null; + String text = source.subSequence(start, end).toString(); + if (text.equals("\n")) { + binding.valueNote.post(() -> binding.valueNote.requestFocus()); + return ""; + } + return text.replace('\n', ' '); + }; + InputFilter[] current = binding.notesTitle.getFilters(); + InputFilter[] filters = new InputFilter[current.length + 1]; + System.arraycopy(current, 0, filters, 0, current.length); + filters[current.length] = lineBreaks; + binding.notesTitle.setFilters(filters); + } + @Override public void activatedActivity() { + int firstVisible = firstVisibleOffset(); + int lastVisible = lastVisibleOffset(); + if (restoredSelectionStart == EditorCursor.NONE + && savedSelectionStart != EditorCursor.NONE + && isOnScreen(savedSelectionStart, firstVisible, lastVisible)) { + // The caret from the last visit, unless the reader has scrolled away from it. + restoredSelectionStart = savedSelectionStart; + restoredSelectionEnd = savedSelectionEnd; + } + savedSelectionStart = EditorCursor.NONE; + savedSelectionEnd = EditorCursor.NONE; + int[] selection = + EditorCursor.activationSelection( + restoredSelectionStart, + restoredSelectionEnd, + firstVisible, + lastVisible, + binding.valueNote.length()); + restoredSelectionStart = EditorCursor.NONE; + restoredSelectionEnd = EditorCursor.NONE; + binding.setActivateEdit(true); + setEditing(true); binding.valueNote.setEnabled(true); binding.valueNote.setFocusable(true); - if (!notePresenter.getNewNotesKey()) - binding.valueNote.setSelection(binding.valueNote.getText().length()); binding.valueNote.setFocusableInTouchMode(true); + binding.valueNote.setSelection(selection[0], selection[1]); binding.valueNote.requestFocus(); if (notePresenter.getNewNotesKey()) { @@ -336,20 +849,29 @@ public void activatedActivity() { } } + private static boolean isOnScreen(int offset, int firstVisible, int lastVisible) { + if (firstVisible == EditorCursor.NONE || lastVisible == EditorCursor.NONE) return false; + return offset >= firstVisible && offset <= lastVisible; + } + @Override public void onDestroy() { super.onDestroy(); if (binding != null) { binding.notesTitle.removeTextChangedListener(titleWatcher); binding.valueNote.removeTextChangedListener(valueWatcher); + binding.notesTitle.removeTextChangedListener(titleHistoryWatcher); + binding.valueNote.removeTextChangedListener(valueHistoryWatcher); + binding.notesTitle.setOnKeyListener(null); + binding.valueNote.setOnKeyListener(null); binding.titleToolbarTagCenter.setOnClickListener(null); binding.titleToolbarTagCollapsed.setOnClickListener(null); binding.valueNote.setOnFocusChangeListener(null); binding.valueNote.setOnClickListener(null); + binding.valueNote.removeCallbacks(commitValueRunnable); binding.scrollProgressIndicator.setProgress(0); } lastCursorPosition = -1; - savedScrollPosition = -1; isKeyboardVisible = false; } @@ -363,10 +885,26 @@ public void loadingNote(Note note) { String title = note.getTitle(); String value = note.getValue(); - binding.notesTitle.setText(title != null && !title.isEmpty() ? title : ""); - - binding.valueNote.setText(value != null ? value : ""); - binding.valueNote.setMovementMethod(CustomLinkMovementMethod.getInstance()); + // Text typed before a recreation that had not reached the database yet. + String pendingTitle = draftTitle; + String pendingValue = draftValue; + draftTitle = null; + draftValue = null; + if (pendingTitle != null) title = pendingTitle; + if (pendingValue != null) value = pendingValue; + + // Loading is not an edit; the presenter starts the history again once the note is in. + historyMuted = true; + try { + binding.notesTitle.setText(title != null && !title.isEmpty() ? title : ""); + binding.valueNote.setText(value != null ? value : ""); + } finally { + historyMuted = false; + } + // After setText: with autoLink, a field that is not editable yet gets a link movement from + // setText, which clears the selection on focus and sent the caret to the start of the + // note. An editable field needs the editing movement. + binding.valueNote.setMovementMethod(EditableLinkMovementMethod.getInstance()); String formattedDate = getString(R.string.lastDateEditNote, lastDayEditNote(note.getDate())); @@ -383,8 +921,40 @@ public void loadingNote(Note note) { changeTag(tag != null ? tag : "", false); updateWordCount(value); + updateReminderChip(note); + + if (pendingTitle != null || pendingValue != null) { + // The presenter takes the loaded note right after this call; hand it the draft once + // it has, so the draft is compared against what is stored and saved. + String restoredTitle = pendingTitle; + String restoredValue = pendingValue; + binding.getRoot() + .post( + () -> + notePresenter.simpleNoteChange( + restoredTitle, restoredValue, false)); + } - if (notePresenter.getNewNotesKey()) { + if (restoredAnchorOffset != EditorCursor.NONE) { + scrollToOffsetWhenLaidOut(restoredAnchorOffset); + restoredAnchorOffset = EditorCursor.NONE; + } else if (restoreSavedPosition && !notePresenter.getNewNotesKey()) { + // A fresh open of an existing note follows the opening settings. + boolean editOnOpen = + NoteOpeningPreferences.opensInEditMode( + noteOpeningPreferences.getOpenMode(), false, false); + Runnable then = editOnOpen ? this::startEditingOnOpen : null; + if (noteOpeningPreferences.restoresLastPosition()) { + restoreSavedPosition(note.getId(), value != null ? value : "", then); + } else if (then != null) { + OneShotPreDrawListener.add(binding.valueNote, then); + } + } + restoreSavedPosition = false; + noteShown = true; + + if (notePresenter.getNewNotesKey() || restoredEditing) { + restoredEditing = false; activatedActivity(); } } diff --git a/app/src/main/java/com/pasich/mynotes/ui/view/activity/noteEditor/NoteExtendedEditorActivity.java b/app/src/main/java/com/pasich/mynotes/ui/view/activity/noteEditor/NoteExtendedEditorActivity.java index 7bfe2e5e..904ec738 100644 --- a/app/src/main/java/com/pasich/mynotes/ui/view/activity/noteEditor/NoteExtendedEditorActivity.java +++ b/app/src/main/java/com/pasich/mynotes/ui/view/activity/noteEditor/NoteExtendedEditorActivity.java @@ -6,8 +6,10 @@ import static com.pasich.mynotes.utils.FormattedDataUtil.lastDayEditNote; import android.content.Intent; +import android.os.Bundle; import android.util.Log; import android.view.LayoutInflater; +import android.view.Menu; import android.view.MenuItem; import android.view.View; import android.view.ViewGroup; @@ -19,8 +21,11 @@ import androidx.core.graphics.Insets; import androidx.core.view.ViewCompat; import androidx.core.view.WindowInsetsCompat; +import androidx.lifecycle.ViewModelProvider; +import com.google.android.material.chip.Chip; import com.pasich.mynotes.R; import com.pasich.mynotes.cache.AppPreferencesCache; +import com.pasich.mynotes.cache.NoteOpeningPreferences; import com.pasich.mynotes.data.model.Note; import com.pasich.mynotes.databinding.ActivityNoteExtendedEditorBinding; import com.pasich.mynotes.extendedEditor.NoteEditorView; @@ -28,10 +33,17 @@ import com.pasich.mynotes.extendedEditor.models.EditorAttachment; import com.pasich.mynotes.extendedEditor.models.SettingsEditorJsBridge; import com.pasich.mynotes.extendedEditor.utils.EditorJSInterface; +import com.pasich.mynotes.extendedEditor.utils.ExtendedViewStateJson; import com.pasich.mynotes.extendedEditor.view.AttachmentActionsDialog; import com.pasich.mynotes.extendedEditor.view.CopyTextDialog; import com.pasich.mynotes.ui.presenter.NotePresenter; import com.pasich.mynotes.ui.view.activity.PhotoViewActivity; +import com.pasich.mynotes.ui.view.widgets.EditorKeyboardBar; +import com.pasich.mynotes.utils.editor.NoteViewState; +import com.pasich.mynotes.utils.editor.NoteViewStateStore; +import com.pasich.mynotes.utils.editor.PositionRestorer; +import com.pasich.mynotes.utils.editor.RetainedEditHistory; +import com.pasich.mynotes.utils.navigation.NoteExtras; import dagger.hilt.android.AndroidEntryPoint; import jakarta.inject.Inject; @@ -48,8 +60,147 @@ public class NoteExtendedEditorActivity result.getResultCode(), result.getData()); } }); + private static final String STATE_READ_MODE = "extended.readMode"; + private static final String STATE_ANCHOR_INDEX = "extended.anchorIndex"; + private static final String STATE_ANCHOR_OFFSET = "extended.anchorOffset"; + private static final String STATE_DRAFT_TITLE = "extended.draftTitle"; + private static final String STATE_DRAFT_JSON = "extended.draftJson"; + private static final String STATE_CHOOSER_KIND = "extended.chooserKind"; + private static final String STATE_CHOOSER_INDEX = "extended.chooserIndex"; + + /** + * Largest unsaved document kept in the saved state. The draft only matters while a save is in + * flight; a bigger one would risk the saved-state size limit for a few milliseconds of + * protection. + */ + private static final int MAX_DRAFT_CHARS = 64 * 1024; + @Inject AppPreferencesCache appPreferencesCache; + @Inject NoteViewStateStore noteViewStateStore; + @Inject NoteOpeningPreferences noteOpeningPreferences; private boolean isReadMode = false; + private MenuItem readModeItem; + + // "Open in editing mode" was chosen, or the note is new: the keyboard opens once the note is + // on screen. + private boolean showKeyboardWhenRendered = false; + + // A fresh open (not a recreation) of an existing note goes back to where it was left. + private boolean restoreSavedPosition = false; + + private int restoredAnchorIndex = -1; + private int restoredAnchorOffset = 0; + private String draftTitle; + private String draftJson; + private RetainedEditHistory retainedEditHistory; + + @Override + public void onCreate(Bundle savedInstanceState) { + if (savedInstanceState != null) { + restoredAnchorIndex = savedInstanceState.getInt(STATE_ANCHOR_INDEX, -1); + restoredAnchorOffset = savedInstanceState.getInt(STATE_ANCHOR_OFFSET, 0); + draftTitle = savedInstanceState.getString(STATE_DRAFT_TITLE); + draftJson = savedInstanceState.getString(STATE_DRAFT_JSON); + } + super.onCreate(savedInstanceState); + retainedEditHistory = new ViewModelProvider(this).get(RetainedEditHistory.class); + if (savedInstanceState != null && binding != null) { + // The page of the screen this one replaced hands its undo history over as it finishes. + binding.noteEditor.setHandedOverHistory(retainedEditHistory::take); + } + // The note loads asynchronously, so this is decided before it arrives; the store is + // injected by super.onCreate. + long openedId = getIntent().getLongExtra(NoteExtras.EXTRA_ID_NOTE, 0); + boolean freshOpen = savedInstanceState == null; + boolean newNote = notePresenter.getNewNotesKey(); + NoteOpeningPreferences.OpenMode mode = noteOpeningPreferences.getOpenMode(); + isReadMode = + freshOpen + ? !NoteOpeningPreferences.opensInEditMode(mode, true, newNote) + : savedInstanceState.getBoolean(STATE_READ_MODE, false); + restoreSavedPosition = + freshOpen + && openedId > 0 + && noteOpeningPreferences.restoresLastPosition() + && hasSavedPosition(openedId); + boolean editOnOpen = freshOpen && !newNote && mode == NoteOpeningPreferences.OpenMode.EDIT; + // A new note opens for typing in every mode, like in the simple editor. + showKeyboardWhenRendered = editOnOpen || (freshOpen && newNote && !isReadMode); + if (binding != null) { + binding.noteEditor.setStartOptions( + isReadMode, noteOpeningPreferences.isDoubleTapToEdit()); + // The caret goes back where it was, so the first block must not take it first. + if (restoreSavedPosition) binding.noteEditor.setAutofocus(false); + binding.noteEditor.setFocusStart(editOnOpen); + } + setEditing(!isReadMode); + if (savedInstanceState != null && binding != null) { + // A picker opened by the previous instance answers this one. + binding.noteEditor.restoreChooserState( + savedInstanceState.getString(STATE_CHOOSER_KIND), + savedInstanceState.getInt(STATE_CHOOSER_INDEX, -1)); + } + } + + private boolean hasSavedPosition(long noteId) { + NoteViewState saved = noteViewStateStore.get(noteId); + return saved != null && saved.extended != null; + } + + @Override + protected void onSaveInstanceState(@NonNull Bundle outState) { + super.onSaveInstanceState(outState); + if (binding == null) return; + outState.putBoolean(STATE_READ_MODE, isReadMode); + outState.putInt(STATE_ANCHOR_INDEX, binding.noteEditor.getAnchorIndex()); + outState.putInt(STATE_ANCHOR_OFFSET, binding.noteEditor.getAnchorOffset()); + if (binding.noteEditor.getChooserKind() != null) { + outState.putString(STATE_CHOOSER_KIND, binding.noteEditor.getChooserKind()); + outState.putInt(STATE_CHOOSER_INDEX, binding.noteEditor.getChooserBlockIndex()); + } + if (notePresenter != null && notePresenter.hasUnsavedChanges()) { + Note note = notePresenter.getNote(); + String json = note.getValueJson(); + if (json == null || json.length() <= MAX_DRAFT_CHARS) { + outState.putString(STATE_DRAFT_TITLE, note.getTitle()); + outState.putString(STATE_DRAFT_JSON, json); + } + } + } + + @Override + protected void onPause() { + super.onPause(); + saveViewState(); + } + + /** + * Keeps where the note is being read or edited, for the next time it is opened: the position + * the page reported last right away, then the one it is at now once the page has answered. A + * report can be older than the last scroll, above all in reading mode, where no caret moves. + */ + private void saveViewState() { + if (binding == null || notePresenter == null) return; + long noteId = notePresenter.getIdKey(); + if (noteId <= 0) return; + putViewState(noteId, binding.noteEditor.getLastViewState()); + binding.noteEditor.readViewState(json -> putViewState(noteId, json)); + } + + private void putViewState(long noteId, String json) { + NoteViewState.Extended state = ExtendedViewStateJson.fromPage(json); + if (state != null) noteViewStateStore.putExtended(noteId, state); + } + + @Override + public void onStop() { + super.onStop(); + if (binding == null || notePresenter == null || !notePresenter.hasNote()) return; + // Write what the presenter already holds, then collect whatever the editor is still + // batching; that answer is written as soon as it arrives. + notePresenter.flushPending(); + binding.noteEditor.requestFlush(); + } @Override protected int getMenuResId() { @@ -61,6 +212,16 @@ protected Toolbar getToolbar() { return binding.toolbar; } + @Override + protected Chip getReminderChip() { + return binding.reminderChip; + } + + @Override + protected EditorKeyboardBar getKeyboardBar() { + return binding.keyboardBar; + } + @Override protected ActivityNoteExtendedEditorBinding inflateBinding(LayoutInflater inflater) { return ActivityNoteExtendedEditorBinding.inflate(inflater); @@ -89,14 +250,87 @@ public void onContentChanged(String json) { runOnUiThread(() -> processTextChange(json)); } + @Override + public void onContentFlushed(String json) { + runOnUiThread( + () -> { + processTextChange(json); + notePresenter.flushPending(); + }); + } + + @Override + public void onViewportAnchor(int blockIndex, int offsetPx) { + runOnUiThread( + () -> { + if (binding != null) { + binding.noteEditor.onViewportAnchor( + blockIndex, offsetPx); + } + }); + } + + @Override + public void onViewState(String json) { + runOnUiThread( + () -> { + if (binding != null) { + binding.noteEditor.onViewState(json); + } + }); + } + + @Override + public void onReadModeChanged(boolean readOnly, boolean byDoubleTap) { + runOnUiThread( + () -> { + if (binding == null) return; + isReadMode = readOnly; + updateReadModeItem(); + // A double tap means "let me type here". + if (!readOnly && byDoubleTap) { + binding.noteEditor.showKeyboard(); + } + }); + } + + @Override + public void onHistoryChanged(boolean canUndo, boolean canRedo) { + runOnUiThread( + () -> { + if (binding != null) { + setUndoRedoState(canUndo, canRedo); + } + }); + } + + @Override + public void onNoteRendered() { + if (binding != null) binding.noteEditor.onNoteRenderedFromBridge(); + runOnUiThread( + () -> { + if (binding == null || !showKeyboardWhenRendered) { + return; + } + showKeyboardWhenRendered = false; + if (!isReadMode) binding.noteEditor.showKeyboard(); + }); + } + @Override public void onTitleChanged(String title) { runOnUiThread(() -> processTitleChange(title)); } + @Override + public void onHistoryExported(String json) { + long noteId = notePresenter.getIdKey(); + runOnUiThread(() -> retainedEditHistory.put(noteId, json)); + } + @Override public void openPhoto(String blockId) { - handleImageOpen(blockId); + runOnUiThread(() -> handleImageOpen(blockId)); } @Override @@ -112,7 +346,9 @@ public void openFile(EditorAttachment att) { @Override public int getNoteId() { - return notePresenter.getNote().getId(); + // Called on the WebView's bridge thread: read the id, never the + // note the UI thread is editing. + return (int) notePresenter.getIdKey(); } @Override @@ -130,6 +366,7 @@ public void onError(String error) { @Override protected void onNewNoteInit(Note note) { binding.noteEditor.load(note); + updateReminderChip(note); } @Override @@ -137,18 +374,32 @@ protected void setNewNoteTitle() { binding.titleToolbarDataCollapsed.setText(getString(R.string.new_note)); } - /** Configures indents taking into account the keyboard for NoteActivity */ + /** + * The window draws edge to edge, so the keyboard does not resize it: the editor ends above the + * keyboard or the navigation bar, whichever is taller, and above the bar with Undo and Redo + * that sits on the keyboard while editing. Without it the keyboard covered the lower half of + * the editor and the caret typed out of sight; the editor page keeps the caret visible when its + * height changes, so the caret line stays above the bar. + */ @Override protected void applyEdgeToEdgeInsets(View rootView) { ViewCompat.setOnApplyWindowInsetsListener( rootView, (v, insets) -> { - // Get indents for system bars Insets systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars()); - - // Set padding at the top for system bars only for the root view + Insets ime = insets.getInsets(WindowInsetsCompat.Type.ime()); + // The bottom is left to the editor and the bar, which follows the keyboard. v.setPadding(v.getPaddingLeft(), systemBars.top, v.getPaddingRight(), 0); - + binding.keyboardBar.onWindowInsets(insets); + int bottom = + Math.max(ime.bottom, systemBars.bottom) + + binding.keyboardBar.getReservedHeight(); + ViewGroup.MarginLayoutParams params = + (ViewGroup.MarginLayoutParams) binding.noteEditor.getLayoutParams(); + if (params.bottomMargin != bottom) { + params.bottomMargin = bottom; + binding.noteEditor.setLayoutParams(params); + } return insets; }); } @@ -236,23 +487,70 @@ public void activatedActivity() { binding.setActivateEdit(true); } + @Override + public boolean onCreateOptionsMenu(Menu menu) { + boolean shown = super.onCreateOptionsMenu(menu); + readModeItem = menu.findItem(R.id.actionRead); + // The note may open in reading mode: the action offers the way out from the start. + updateReadModeItem(); + return shown; + } + + /** + * Shows the action that leaves the current mode: Edit while reading, Read while editing. Undo + * and Redo, on the bar above the keyboard and in More, apply only while editing. + */ + private void updateReadModeItem() { + setEditing(!isReadMode); + if (readModeItem == null) return; + readModeItem.setIcon(isReadMode ? R.drawable.ic_edit : R.drawable.ic_read); + readModeItem.setTitle(isReadMode ? R.string.read_mode_exit : R.string.read_mode_enter); + } + @Override public boolean onOptionsItemSelected(@NonNull MenuItem item) { if (item.getItemId() == R.id.actionRead) { + // Before the page is ready a switch would be lost; the item keeps showing the truth. + if (!binding.noteEditor.isEditorReady()) return true; isReadMode = !isReadMode; binding.noteEditor.actionRead(); - item.setIcon(isReadMode ? R.drawable.ic_edit : R.drawable.ic_read); + updateReadModeItem(); return true; } return super.onOptionsItemSelected(item); } + @Override + protected void undoEdit() { + if (binding == null || isReadMode) return; + binding.noteEditor.undo(); + } + + @Override + protected void redoEdit() { + if (binding == null || isReadMode) return; + binding.noteEditor.redo(); + } + + /** + * The page keeps the history and starts it again whenever it loads a note, so it never reaches + * across notes or into a version restored from the history. A page recreated with the screen + * takes over the previous page's history ({@link RetainedEditHistory}); a new page starts with + * none. + */ + @Override + public void resetEditHistory() { + // Nothing to do here; see above. + } + @Override public void onDestroy() { super.onDestroy(); if (binding != null) { binding.titleToolbarTagCollapsed.setOnClickListener(null); - binding.noteEditor.release(); + // The WebView goes once the page has handed over its last edit and, when the screen + // is only being recreated, its undo history. + binding.noteEditor.release(isChangingConfigurations()); if (binding.noteEditor.getParent() instanceof ViewGroup) { ((ViewGroup) binding.noteEditor.getParent()).removeView(binding.noteEditor); } @@ -269,7 +567,42 @@ public void loadingNote(Note note) { changeTag(note.getTag() != null ? note.getTag() : "", false); binding.titleToolbarDataCollapsed.setText( getString(R.string.lastDateEditNote, lastDayEditNote(note.getDate()))); - binding.noteEditor.load(note); + + if (restoredAnchorIndex >= 0) { + binding.noteEditor.setRestoreAnchor(restoredAnchorIndex, restoredAnchorOffset); + restoredAnchorIndex = -1; + } else if (restoreSavedPosition) { + restoreSavedPosition = false; + NoteViewState saved = noteViewStateStore.get(note.getId()); + PositionRestorer.ExtendedTarget target = + PositionRestorer.restoreExtended( + saved != null ? saved.extended : null, + ExtendedViewStateJson.blockIds(note.getValueJson())); + if (target.match != PositionRestorer.Match.TOP) { + binding.noteEditor.setRestoreViewState( + ExtendedViewStateJson.toPage(target).toString()); + } + } + + String pendingTitle = draftTitle; + String pendingJson = draftJson; + draftTitle = null; + draftJson = null; + if (pendingTitle == null && pendingJson == null) { + binding.noteEditor.load(note); + return; + } + + // Edits made before a recreation that had not reached the database: show them, and hand + // them to the presenter once it holds the loaded note so they are compared with what is + // stored and saved. + Note shown = new Note(); + shown.copyFrom(note); + shown.setId(note.getId()); + if (pendingTitle != null) shown.setTitle(pendingTitle); + if (pendingJson != null) shown.setValueJson(pendingJson); + binding.noteEditor.load(shown); + binding.getRoot().post(() -> notePresenter.extendedNoteChange(pendingTitle, pendingJson)); } @Override diff --git a/app/src/main/java/com/pasich/mynotes/ui/view/dialogs/CustomRepeatDialog.java b/app/src/main/java/com/pasich/mynotes/ui/view/dialogs/CustomRepeatDialog.java new file mode 100644 index 00000000..a9c761fd --- /dev/null +++ b/app/src/main/java/com/pasich/mynotes/ui/view/dialogs/CustomRepeatDialog.java @@ -0,0 +1,209 @@ +package com.pasich.mynotes.ui.view.dialogs; + +import android.app.Dialog; +import android.content.DialogInterface; +import android.os.Bundle; +import android.text.Editable; +import android.text.TextWatcher; +import android.view.View; +import android.view.inputmethod.EditorInfo; +import android.widget.Button; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.app.AlertDialog; +import androidx.fragment.app.DialogFragment; +import com.google.android.material.chip.Chip; +import com.google.android.material.chip.ChipGroup; +import com.google.android.material.dialog.MaterialAlertDialogBuilder; +import com.google.android.material.textfield.TextInputEditText; +import com.google.android.material.textfield.TextInputLayout; +import com.pasich.mynotes.R; +import com.pasich.mynotes.data.model.RepeatRule; +import com.pasich.mynotes.utils.reminder.RepeatRuleFormatter; + +/** + * "Every [N] [hours / days / weeks / months / years]". The unit choices read "3 hours", "3 days"… + * for the number typed, and the field's helper line spells out the result, "Every 3 hours". + * + *

The answer goes to the parent fragment manager under {@link #REQUEST_KEY}; {@link + * #RESULT_APPLIED} is false when the dialog was cancelled, so the caller can restore its choice. + */ +public class CustomRepeatDialog extends DialogFragment { + + public static final String TAG = "CustomRepeatDialog"; + public static final String REQUEST_KEY = "customRepeat"; + public static final String RESULT_APPLIED = "applied"; + public static final String RESULT_INTERVAL = "interval"; + public static final String RESULT_UNIT = "unit"; + + private static final String ARG_INTERVAL = "interval"; + private static final String ARG_UNIT = "unit"; + + private static final int[] UNIT_CHIPS = { + R.id.chipUnitHours, + R.id.chipUnitDays, + R.id.chipUnitWeeks, + R.id.chipUnitMonths, + R.id.chipUnitYears + }; + + private TextInputLayout intervalLayout; + private TextInputEditText intervalInput; + private ChipGroup unitChips; + private int lastValidInterval; + + public static CustomRepeatDialog newInstance(int interval, @NonNull RepeatRule.Unit unit) { + CustomRepeatDialog dialog = new CustomRepeatDialog(); + Bundle args = new Bundle(); + args.putInt(ARG_INTERVAL, interval); + args.putString(ARG_UNIT, unit.name()); + dialog.setArguments(args); + return dialog; + } + + @NonNull + @Override + public Dialog onCreateDialog(@Nullable Bundle savedInstanceState) { + View view = getLayoutInflater().inflate(R.layout.dialog_repeat_custom, null); + intervalLayout = view.findViewById(R.id.repeatIntervalLayout); + intervalInput = view.findViewById(R.id.repeatIntervalInput); + unitChips = view.findViewById(R.id.repeatUnitChips); + + Bundle args = requireArguments(); + lastValidInterval = clamp(args.getInt(ARG_INTERVAL, 1)); + if (savedInstanceState == null) { + String text = String.valueOf(lastValidInterval); + intervalInput.setText(text); + intervalInput.setSelection(text.length()); + unitChips.check(chipFor(unitFrom(args.getString(ARG_UNIT)))); + } + + AlertDialog dialog = + new MaterialAlertDialogBuilder(requireContext()) + .setTitle(R.string.reminder_repeat_custom_title) + .setView(view) + .setPositiveButton(R.string.reminder_repeat_custom_apply, null) + .setNegativeButton(R.string.reminder_cancel, (d, w) -> sendCancelled()) + .create(); + + dialog.setOnShowListener( + d -> { + dialog.getButton(DialogInterface.BUTTON_POSITIVE) + .setOnClickListener(v -> apply()); + refresh(); + }); + intervalInput.addTextChangedListener( + new TextWatcher() { + @Override + public void beforeTextChanged(CharSequence s, int st, int c, int a) {} + + @Override + public void onTextChanged(CharSequence s, int st, int b, int c) {} + + @Override + public void afterTextChanged(Editable s) { + refresh(); + } + }); + intervalInput.setOnEditorActionListener( + (v, actionId, event) -> { + if (actionId != EditorInfo.IME_ACTION_DONE) return false; + apply(); + return true; + }); + unitChips.setOnCheckedStateChangeListener((group, ids) -> refresh()); + return dialog; + } + + @Override + public void onCancel(@NonNull DialogInterface dialog) { + super.onCancel(dialog); + sendCancelled(); + } + + private void refresh() { + if (intervalInput == null) return; + int typed = typedInterval(); + boolean valid = typed >= RepeatRule.MIN_INTERVAL && typed <= RepeatRule.MAX_INTERVAL; + if (valid) lastValidInterval = typed; + + RepeatRule.Unit[] units = RepeatRule.Unit.values(); + for (int i = 0; i < UNIT_CHIPS.length; i++) { + Chip chip = unitChips.findViewById(UNIT_CHIPS[i]); + chip.setText( + RepeatRuleFormatter.unitCount(requireContext(), units[i], lastValidInterval)); + } + + if (valid) { + intervalLayout.setError(null); + intervalLayout.setHelperText( + RepeatRuleFormatter.summary( + requireContext(), RepeatRule.every(typed, selectedUnit(), null))); + } else { + intervalLayout.setHelperText(null); + // An empty field is a number being typed, not a mistake yet. + intervalLayout.setError( + typed == EMPTY ? null : getString(R.string.reminder_repeat_custom_error)); + } + Dialog dialog = getDialog(); + if (dialog instanceof AlertDialog) { + Button positive = ((AlertDialog) dialog).getButton(DialogInterface.BUTTON_POSITIVE); + if (positive != null) positive.setEnabled(valid); + } + } + + private void apply() { + int typed = typedInterval(); + if (typed < RepeatRule.MIN_INTERVAL || typed > RepeatRule.MAX_INTERVAL) return; + Bundle result = new Bundle(); + result.putBoolean(RESULT_APPLIED, true); + result.putInt(RESULT_INTERVAL, typed); + result.putString(RESULT_UNIT, selectedUnit().name()); + getParentFragmentManager().setFragmentResult(REQUEST_KEY, result); + dismiss(); + } + + private void sendCancelled() { + Bundle result = new Bundle(); + result.putBoolean(RESULT_APPLIED, false); + getParentFragmentManager().setFragmentResult(REQUEST_KEY, result); + } + + private static final int EMPTY = -1; + + private int typedInterval() { + Editable text = intervalInput.getText(); + if (text == null || text.length() == 0) return EMPTY; + try { + return Integer.parseInt(text.toString().trim()); + } catch (NumberFormatException e) { + return 0; + } + } + + @NonNull + private RepeatRule.Unit selectedUnit() { + int checked = unitChips.getCheckedChipId(); + for (int i = 0; i < UNIT_CHIPS.length; i++) { + if (UNIT_CHIPS[i] == checked) return RepeatRule.Unit.values()[i]; + } + return RepeatRule.Unit.DAYS; + } + + private static int chipFor(@NonNull RepeatRule.Unit unit) { + return UNIT_CHIPS[unit.ordinal()]; + } + + @NonNull + private static RepeatRule.Unit unitFrom(@Nullable String name) { + try { + return name != null ? RepeatRule.Unit.valueOf(name) : RepeatRule.Unit.DAYS; + } catch (IllegalArgumentException e) { + return RepeatRule.Unit.DAYS; + } + } + + private static int clamp(int interval) { + return Math.max(RepeatRule.MIN_INTERVAL, Math.min(RepeatRule.MAX_INTERVAL, interval)); + } +} diff --git a/app/src/main/java/com/pasich/mynotes/ui/view/dialogs/EditHistoryRows.java b/app/src/main/java/com/pasich/mynotes/ui/view/dialogs/EditHistoryRows.java new file mode 100644 index 00000000..c5469b00 --- /dev/null +++ b/app/src/main/java/com/pasich/mynotes/ui/view/dialogs/EditHistoryRows.java @@ -0,0 +1,46 @@ +package com.pasich.mynotes.ui.view.dialogs; + +import android.view.View; +import android.view.ViewGroup; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import com.pasich.mynotes.base.view.MoreNoteNoteActivityView; + +/** + * Undo and Redo in the editor's More sheet. They are there while the note is being edited, so the + * history stays reachable with the keyboard (and the bar above it) put away, and are dimmed and + * disabled when there is nothing to take back or bring back. + */ +final class EditHistoryRows { + + /** The Material 3 disabled content opacity. */ + static final float DISABLED_ALPHA = 0.38f; + + private EditHistoryRows() {} + + /** Shows the rows while {@code editor} edits a note and enables them by its history. */ + static void apply( + @NonNull View group, + @NonNull View undo, + @NonNull View redo, + @Nullable MoreNoteNoteActivityView editor) { + boolean editing = editor != null && editor.isEditingNote(); + group.setVisibility(editing ? View.VISIBLE : View.GONE); + setRowEnabled(undo, editing && editor.canUndoEdit()); + setRowEnabled(redo, editing && editor.canRedoEdit()); + } + + /** Disables a row and dims its icon and label; the row's background keeps its colour. */ + static void setRowEnabled(@NonNull View row, boolean enabled) { + row.setEnabled(enabled); + float alpha = enabled ? 1f : DISABLED_ALPHA; + if (row instanceof ViewGroup) { + ViewGroup group = (ViewGroup) row; + for (int i = 0; i < group.getChildCount(); i++) { + group.getChildAt(i).setAlpha(alpha); + } + } else { + row.setAlpha(alpha); + } + } +} diff --git a/app/src/main/java/com/pasich/mynotes/ui/view/dialogs/MeetEnclyDialog.java b/app/src/main/java/com/pasich/mynotes/ui/view/dialogs/MeetEnclyDialog.java new file mode 100644 index 00000000..7c64b4fb --- /dev/null +++ b/app/src/main/java/com/pasich/mynotes/ui/view/dialogs/MeetEnclyDialog.java @@ -0,0 +1,52 @@ +package com.pasich.mynotes.ui.view.dialogs; + +import android.content.Intent; +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import com.google.android.material.bottomsheet.BottomSheetDialog; +import com.pasich.mynotes.base.dialog.BaseDialogBottomSheets; +import com.pasich.mynotes.databinding.DialogMeetEnclyBinding; +import com.pasich.mynotes.ui.view.activity.MeetEnclyActivity; + +/** The one-time introduction of Encly, shown on the first start of the version that brings it. */ +public class MeetEnclyDialog extends BaseDialogBottomSheets { + + private DialogMeetEnclyBinding binding; + + public MeetEnclyDialog() {} + + public static MeetEnclyDialog newInstance() { + return new MeetEnclyDialog(); + } + + @Override + public View onCreateView( + @NonNull LayoutInflater inflater, + @Nullable ViewGroup container, + @Nullable Bundle savedInstanceState) { + setState((BottomSheetDialog) requireDialog()); + binding = DialogMeetEnclyBinding.inflate(inflater, container, false); + initListeners(); + return binding.getRoot(); + } + + @Override + public void initListeners() { + binding.moreButton.setOnClickListener( + v -> { + startActivity(new Intent(requireActivity(), MeetEnclyActivity.class)); + dismiss(); + }); + binding.laterButton.setOnClickListener(v -> dismiss()); + } + + @Override + public void onDestroyView() { + super.onDestroyView(); + binding = null; + } +} diff --git a/app/src/main/java/com/pasich/mynotes/ui/view/dialogs/MoreNoteDialog.java b/app/src/main/java/com/pasich/mynotes/ui/view/dialogs/MoreNoteDialog.java index 7625a4ba..0c2e6cdd 100644 --- a/app/src/main/java/com/pasich/mynotes/ui/view/dialogs/MoreNoteDialog.java +++ b/app/src/main/java/com/pasich/mynotes/ui/view/dialogs/MoreNoteDialog.java @@ -25,6 +25,7 @@ import com.pasich.mynotes.ui.contract.dialogs.MoreNoteDialogContract; import com.pasich.mynotes.ui.presenter.dialogs.MoreNoteDialogPresenter; import com.pasich.mynotes.utils.navigation.GoogleTranslateHelper; +import com.pasich.mynotes.utils.reminder.RepeatRuleFormatter; import com.pasich.mynotes.utils.tool.TextStyleTool; import dagger.hilt.android.AndroidEntryPoint; import java.text.SimpleDateFormat; @@ -94,7 +95,10 @@ public View onCreateView( textStylePreferences.addButton(binding.settingsActivity.textStyleItem); if (rootActivity != RootActivity.MainActivity) { - binding.setReminder.setBackgroundResource(R.drawable.bg_item_full); + // In the editor the reminder and the version history form a group of their own. + binding.setReminder.setBackgroundResource(R.drawable.bg_item_start); + } else { + binding.setReminder.setBackgroundResource(R.drawable.bg_item_middle); } return binding.getRoot(); @@ -139,7 +143,12 @@ private void updateReminderMenuState(Note note) { boolean hasReminder = note != null && note.hasReminder(); if (hasReminder) { SimpleDateFormat fmt = new SimpleDateFormat("d MMM · HH:mm", Locale.getDefault()); - binding.reminderSubtitle.setText(fmt.format(new Date(note.getReminderTime()))); + String when = fmt.format(new Date(note.getReminderTime())); + String repeat = RepeatRuleFormatter.summary(requireContext(), note.getReminderRepeat()); + binding.reminderSubtitle.setText( + repeat == null + ? when + : getString(R.string.reminder_time_with_repeat, when, repeat)); binding.reminderSubtitle.setVisibility(View.VISIBLE); } else { binding.reminderSubtitle.setVisibility(View.GONE); @@ -279,6 +288,19 @@ public void onStopTrackingTouch(@NonNull Slider slider) { binding.quickCopy.setVisibility(GONE); } + // The sheet stays open, so Undo can be pressed several times; the rows follow the + // history as it changes. + binding.moreUndo.setOnClickListener( + v -> { + if (noteActivity != null) noteActivity.undoLastEdit(); + }); + binding.moreRedo.setOnClickListener( + v -> { + if (noteActivity != null) noteActivity.redoLastEdit(); + }); + if (noteActivity != null) noteActivity.setEditHistoryObserver(this::applyEditHistory); + applyEditHistory(); + } else { binding.actionPanelActivate.setOnClickListener( view -> { @@ -322,6 +344,18 @@ public void onStopTrackingTouch(@NonNull Slider slider) { dismiss(); }); + binding.versionHistory.setOnClickListener( + v -> { + Note note = mPresenter.getNote(); + if (note == null) return; + if (rootActivity == RootActivity.MainActivity) { + if (mainActivity != null) mainActivity.openVersionHistory(note.getId()); + } else if (noteActivity != null) { + noteActivity.openVersionHistory(); + } + dismiss(); + }); + binding.moveToTrash.setOnClickListener( v -> { mPresenter.noteMoveToTrash(); @@ -334,6 +368,13 @@ public void onStopTrackingTouch(@NonNull Slider slider) { }); } + /** Shows Undo and Redo while the note is edited and enables them by its history. */ + private void applyEditHistory() { + if (binding == null) return; + EditHistoryRows.apply( + binding.editHistoryGroup, binding.moreUndo, binding.moreRedo, noteActivity); + } + @Override public void onDismiss(@NonNull DialogInterface dialog) { super.onDismiss(dialog); @@ -346,6 +387,9 @@ public void onDismiss(@NonNull DialogInterface dialog) { binding.quickTranslate.setOnClickListener(null); binding.quickPin.setOnClickListener(null); binding.settingsActivity.textStyleItem.setOnClickListener(null); + binding.moreUndo.setOnClickListener(null); + binding.moreRedo.setOnClickListener(null); + if (noteActivity != null) noteActivity.setEditHistoryObserver(null); noteActivity = null; } else { mainActivity = null; @@ -357,6 +401,7 @@ public void onDismiss(@NonNull DialogInterface dialog) { } binding.setReminder.setOnClickListener(null); + binding.versionHistory.setOnClickListener(null); binding.moveToTrash.setOnClickListener(null); } diff --git a/app/src/main/java/com/pasich/mynotes/ui/view/dialogs/NoteVersionSheet.java b/app/src/main/java/com/pasich/mynotes/ui/view/dialogs/NoteVersionSheet.java new file mode 100644 index 00000000..8de00521 --- /dev/null +++ b/app/src/main/java/com/pasich/mynotes/ui/view/dialogs/NoteVersionSheet.java @@ -0,0 +1,181 @@ +package com.pasich.mynotes.ui.view.dialogs; + +import android.content.Context; +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.VisibleForTesting; +import com.google.android.material.bottomsheet.BottomSheetDialog; +import com.pasich.mynotes.base.dialog.BaseDialogBottomSheets; +import com.pasich.mynotes.data.database.entities.NoteVersionEntity; +import com.pasich.mynotes.data.history.NoteHistory; +import com.pasich.mynotes.data.history.NoteVersionReason; +import com.pasich.mynotes.data.history.NoteVersionRestore; +import com.pasich.mynotes.data.model.Note; +import com.pasich.mynotes.databinding.SheetNoteVersionBinding; +import com.pasich.mynotes.ui.history.NoteVersionText; + +/** + * Preview of one kept version next to the current note, with the part where they differ highlighted + * in both, and the way to restore it. + * + *

The sheet holds no data of its own: the host activity has the versions and the current note, + * so the sheet survives recreation. A recreated host loads them again; the sheet waits for that + * ({@link #onHostDataChanged}) and closes itself only if its version has gone. + */ +public class NoteVersionSheet extends BaseDialogBottomSheets { + + public static final String TAG = "NoteVersionSheet"; + private static final String ARG_VERSION_ID = "versionId"; + + /** Longest stretch of the version shown, windowed around the difference. */ + private static final int VERSION_LIMIT = 4000; + + /** Longest stretch of the current note shown; it is there for comparison only. */ + private static final int CURRENT_LIMIT = 320; + + /** The activity hosting the sheet. */ + public interface Host { + @Nullable + Note currentNote(); + + @Nullable + NoteVersionEntity findVersion(long versionId); + + void onRestoreRequested(long versionId); + + /** Whether the note and its versions have been loaded. */ + boolean isHistoryLoaded(); + } + + /** What the sheet does with what its host has. */ + @VisibleForTesting + enum Show { + /** Show the version. */ + BIND, + /** The host is still loading (it was recreated): wait for it. */ + WAIT, + /** The note or the version is gone: close. */ + CLOSE + } + + @VisibleForTesting + static Show decide( + boolean loaded, @Nullable Note current, @Nullable NoteVersionEntity version) { + if (current != null && version != null) return Show.BIND; + return loaded ? Show.CLOSE : Show.WAIT; + } + + private boolean bound; + + @Nullable private Host host; + private SheetNoteVersionBinding binding; + private long versionId; + + @NonNull + public static NoteVersionSheet newInstance(long versionId) { + NoteVersionSheet sheet = new NoteVersionSheet(); + Bundle args = new Bundle(); + args.putLong(ARG_VERSION_ID, versionId); + sheet.setArguments(args); + return sheet; + } + + @Override + public void onAttach(@NonNull Context context) { + super.onAttach(context); + if (context instanceof Host h) host = h; + } + + @Override + public void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + versionId = getArguments() != null ? getArguments().getLong(ARG_VERSION_ID) : 0L; + } + + @Nullable + @Override + public View onCreateView( + @NonNull LayoutInflater inflater, + @Nullable ViewGroup container, + @Nullable Bundle savedInstanceState) { + setState((BottomSheetDialog) requireDialog()); + binding = SheetNoteVersionBinding.inflate(inflater, container, false); + return binding.getRoot(); + } + + @Override + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); + initListeners(); + onHostDataChanged(); + } + + /** The host has loaded, or reloaded, the note or its versions. */ + public void onHostDataChanged() { + if (binding == null || host == null) return; + Note current = host.currentNote(); + NoteVersionEntity version = host.findVersion(versionId); + switch (decide(host.isHistoryLoaded(), current, version)) { + case BIND -> { + bind(current, version); + bound = true; + } + case CLOSE -> dismissAllowingStateLoss(); + case WAIT -> { + // Shown again once the host has its data. + } + } + binding.versionSheetRestore.setEnabled(bound && binding.versionSheetRestore.isEnabled()); + } + + private void bind(@NonNull Note current, @NonNull NoteVersionEntity version) { + Context context = requireContext(); + binding.versionSheetTitle.setText(NoteVersionText.when(context, version.createdAt)); + binding.versionSheetReason.setText( + NoteVersionText.reasonLabel(NoteVersionReason.fromStored(version.reason))); + + String versionText = NoteVersionText.readable(version.title, version.value); + String currentText = NoteVersionText.readable(current.getTitle(), current.getValue()); + binding.versionSheetText.setText( + NoteVersionText.highlighted(context, versionText, currentText, VERSION_LIMIT)); + binding.versionSheetCurrent.setText( + NoteVersionText.highlighted(context, currentText, versionText, CURRENT_LIMIT)); + + Note restored = new Note(); + restored.copyFrom(current); + NoteVersionRestore plan = NoteVersionRestore.plan(current, version); + plan.applyTo(restored); + boolean same = NoteHistory.sameText(current, restored); + binding.versionSheetSame.setVisibility(same ? View.VISIBLE : View.GONE); + binding.versionSheetRestore.setEnabled(!same); + binding.versionSheetAttachments.setVisibility( + plan.droppedAttachments > 0 ? View.VISIBLE : View.GONE); + } + + @Override + public void initListeners() { + binding.versionSheetClose.setOnClickListener(v -> dismiss()); + binding.versionSheetRestore.setOnClickListener( + v -> { + Host target = host; + dismiss(); + if (target != null) target.onRestoreRequested(versionId); + }); + } + + @Override + public void onDestroyView() { + binding = null; + super.onDestroyView(); + } + + @Override + public void onDetach() { + host = null; + super.onDetach(); + } +} diff --git a/app/src/main/java/com/pasich/mynotes/ui/view/dialogs/ReminderPickerBottomSheet.java b/app/src/main/java/com/pasich/mynotes/ui/view/dialogs/ReminderPickerBottomSheet.java index c0133da6..a8814abb 100644 --- a/app/src/main/java/com/pasich/mynotes/ui/view/dialogs/ReminderPickerBottomSheet.java +++ b/app/src/main/java/com/pasich/mynotes/ui/view/dialogs/ReminderPickerBottomSheet.java @@ -5,7 +5,6 @@ import android.content.pm.PackageManager; import android.os.Build; import android.os.Bundle; -import android.provider.Settings; import android.util.Log; import android.view.LayoutInflater; import android.view.View; @@ -19,6 +18,7 @@ import androidx.core.app.ActivityCompat; import com.google.android.material.bottomsheet.BottomSheetDialogFragment; import com.google.android.material.button.MaterialButton; +import com.google.android.material.chip.Chip; import com.google.android.material.chip.ChipGroup; import com.google.android.material.datepicker.CalendarConstraints; import com.google.android.material.datepicker.DateValidatorPointForward; @@ -28,14 +28,17 @@ import com.pasich.mynotes.R; import com.pasich.mynotes.data.DataManager; import com.pasich.mynotes.data.model.Note; -import com.pasich.mynotes.data.model.ReminderRepeat; +import com.pasich.mynotes.data.model.RepeatRule; +import com.pasich.mynotes.utils.reminder.ReminderDraft; import com.pasich.mynotes.utils.reminder.ReminderManager; +import com.pasich.mynotes.utils.reminder.RepeatRuleFormatter; import dagger.hilt.android.AndroidEntryPoint; import io.reactivex.disposables.CompositeDisposable; import java.text.SimpleDateFormat; import java.util.Calendar; import java.util.Date; import java.util.Locale; +import java.util.TimeZone; import javax.inject.Inject; @AndroidEntryPoint @@ -43,13 +46,27 @@ public class ReminderPickerBottomSheet extends BottomSheetDialogFragment { private static final String TAG = "ReminderPicker"; private static final String ARG_NOTE_ID = "noteId"; + private static final String TAG_DATE_PICKER = "datePicker"; + private static final String TAG_TIME_PICKER = "timePicker"; @Inject DataManager dataManager; private int noteId; private Long selectedTime = null; - private ReminderRepeat selectedRepeat = ReminderRepeat.NONE; private Note currentNote; + + /** The reminder as stored when the sheet opened, so re-saving it keeps its anchor. */ + private Long originalTime = null; + + private RepeatRule originalRule = RepeatRule.NONE; + + /** The rule behind the Custom chip, or null while it has none. */ + @Nullable private RepeatRule customRule; + + /** The preset chip to fall back to when the custom dialog is cancelled. */ + private int lastPresetChipId = R.id.chipNone; + + private Chip chipCustom; private final CompositeDisposable disposables = new CompositeDisposable(); private View selectedTimeCard; @@ -62,6 +79,13 @@ public class ReminderPickerBottomSheet extends BottomSheetDialogFragment { private View intervalDivider; private com.google.android.material.materialswitch.MaterialSwitch switchRepeatInterval; private ChipGroup intervalChips; + private TextView pastTimeError; + + /** A day picked in the date picker whose time is being chosen (UTC midnight), or null. */ + @Nullable private Long pickedDateUtc; + + /** What was chosen before the sheet was recreated (a rotation); null for a fresh sheet. */ + @Nullable private ReminderDraft restoredDraft; private final ActivityResultLauncher notifPermLauncher = registerForActivityResult( @@ -83,6 +107,10 @@ public static ReminderPickerBottomSheet newInstance(int noteId) { public void onCreate(@Nullable Bundle savedInstanceState) { super.onCreate(savedInstanceState); noteId = requireArguments().getInt(ARG_NOTE_ID, -1); + restoredDraft = ReminderDraft.restore(savedInstanceState); + getChildFragmentManager() + .setFragmentResultListener( + CustomRepeatDialog.REQUEST_KEY, this, (key, b) -> onCustomRepeat(b)); } @Nullable @@ -97,11 +125,13 @@ public View onCreateView( selectedTimeDisplay = view.findViewById(R.id.selectedTimeDisplay); repeatLabel = view.findViewById(R.id.repeatLabel); repeatChips = view.findViewById(R.id.repeatChips); + chipCustom = view.findViewById(R.id.chipCustom); btnSave = view.findViewById(R.id.btnSave); btnDeleteReminder = view.findViewById(R.id.btnDeleteReminder); intervalDivider = view.findViewById(R.id.intervalDivider); switchRepeatInterval = view.findViewById(R.id.switchRepeatInterval); intervalChips = view.findViewById(R.id.intervalChips); + pastTimeError = view.findViewById(R.id.pastTimeError); switchRepeatInterval.setOnCheckedChangeListener( (btn, checked) -> { @@ -114,6 +144,15 @@ public View onCreateView( } }); + repeatChips.setOnCheckedStateChangeListener( + (group, checkedIds) -> { + if (!checkedIds.isEmpty() && checkedIds.get(0) != R.id.chipCustom) { + lastPresetChipId = checkedIds.get(0); + } + }); + // A click rather than a check: tapping Custom again, already selected, edits the rule. + chipCustom.setOnClickListener(v -> openCustomRepeat()); + intervalChips.setOnCheckedStateChangeListener( (group, checkedIds) -> { if (!checkedIds.isEmpty()) { @@ -141,16 +180,75 @@ public View onCreateView( view.findViewById(R.id.btnCancel).setOnClickListener(v -> dismiss()); btnDeleteReminder.setOnClickListener(v -> deleteReminder()); + if (restoredDraft != null) applyDraft(restoredDraft); + reattachPickers(); return view; } + @Override + public void onSaveInstanceState(@NonNull Bundle outState) { + super.onSaveInstanceState(outState); + if (repeatChips == null) return; + ReminderDraft draft = new ReminderDraft(); + draft.time = selectedTime; + draft.repeatChipId = repeatChips.getCheckedChipId(); + draft.customRule = customRule; + draft.intervalMinutes = switchRepeatInterval.isChecked() ? selectedIntervalMinutes : 0; + draft.pickedDateUtc = pickedDateUtc; + draft.save(outState); + } + + /** Puts back what was chosen before the sheet was recreated. */ + private void applyDraft(@NonNull ReminderDraft draft) { + pickedDateUtc = draft.pickedDateUtc; + if (draft.time != null) applyPreset(draft.time); + if (draft.customRule != null) { + showRepeatRule(draft.customRule); + if (draft.repeatChipId != R.id.chipCustom && draft.repeatChipId != View.NO_ID) { + repeatChips.check(draft.repeatChipId); + } + } else if (draft.repeatChipId != View.NO_ID && draft.repeatChipId != 0) { + repeatChips.check(draft.repeatChipId); + } + if (draft.intervalMinutes > 0) { + switchRepeatInterval.setChecked(true); + selectedIntervalMinutes = draft.intervalMinutes; + setIntervalChip(draft.intervalMinutes); + } + } + + /** + * Pickers open over the sheet are recreated with it, but without their listeners: an answer + * from them would otherwise be lost. + */ + @SuppressWarnings("unchecked") + private void reattachPickers() { + if (getChildFragmentManager().findFragmentByTag(TAG_DATE_PICKER) + instanceof MaterialDatePicker date) { + ((MaterialDatePicker) date).addOnPositiveButtonClickListener(this::onDatePicked); + } + if (getChildFragmentManager().findFragmentByTag(TAG_TIME_PICKER) + instanceof MaterialTimePicker time) { + time.addOnPositiveButtonClickListener(v -> onTimePicked(time)); + } + } + private void prefillExistingReminder(Note note) { + if (note.hasReminder() && restoredDraft != null) { + // The sheet was recreated: what the user chose stays, the stored reminder is only + // what re-saving compares with. + originalTime = note.getReminderTime(); + originalRule = RepeatRule.parse(note.getReminderRepeat()); + btnDeleteReminder.setVisibility(View.VISIBLE); + return; + } if (note.hasReminder()) { selectedTime = note.getReminderTime(); - selectedRepeat = ReminderRepeat.from(note.getReminderRepeat()); + originalTime = note.getReminderTime(); + originalRule = RepeatRule.parse(note.getReminderRepeat()); updateTimeDisplay(); showRepeatSection(); - setRepeatChip(selectedRepeat); + showRepeatRule(originalRule); btnDeleteReminder.setVisibility(View.VISIBLE); btnSave.setEnabled(true); int existingInterval = note.getReminderIntervalMinutes(); @@ -198,12 +296,14 @@ private long nextWeekMorning() { private void applyPreset(long time) { selectedTime = time; + if (pastTimeError != null) pastTimeError.setVisibility(View.GONE); updateTimeDisplay(); showRepeatSection(); btnSave.setEnabled(true); } private void showDatePicker() { + if (getChildFragmentManager().findFragmentByTag(TAG_DATE_PICKER) != null) return; CalendarConstraints constraints = new CalendarConstraints.Builder() .setValidator(DateValidatorPointForward.now()) @@ -215,51 +315,58 @@ private void showDatePicker() { .setSelection(MaterialDatePicker.todayInUtcMilliseconds()) .setCalendarConstraints(constraints) .build(); + datePicker.addOnPositiveButtonClickListener(this::onDatePicked); + datePicker.show(getChildFragmentManager(), TAG_DATE_PICKER); + } - datePicker.addOnPositiveButtonClickListener( - dateMs -> { - Calendar dateCal = Calendar.getInstance(); - dateCal.setTimeInMillis(dateMs); - - Calendar now = Calendar.getInstance(); - int defaultHour = - (dateCal.get(Calendar.DAY_OF_YEAR) == now.get(Calendar.DAY_OF_YEAR) - && dateCal.get(Calendar.YEAR) == now.get(Calendar.YEAR)) - ? now.get(Calendar.HOUR_OF_DAY) - : 9; - int defaultMinute = - (defaultHour == now.get(Calendar.HOUR_OF_DAY)) - ? now.get(Calendar.MINUTE) + 1 - : 0; - - MaterialTimePicker timePicker = - new MaterialTimePicker.Builder() - .setTimeFormat(TimeFormat.CLOCK_24H) - .setHour(defaultHour) - .setMinute(defaultMinute) - .build(); - - timePicker.addOnPositiveButtonClickListener( - v -> { - dateCal.set(Calendar.HOUR_OF_DAY, timePicker.getHour()); - dateCal.set(Calendar.MINUTE, timePicker.getMinute()); - dateCal.set(Calendar.SECOND, 0); - dateCal.set(Calendar.MILLISECOND, 0); - if (dateCal.getTimeInMillis() <= System.currentTimeMillis()) { - Toast.makeText( - requireContext(), - R.string.reminder_past_time_error, - Toast.LENGTH_SHORT) - .show(); - return; - } - applyPreset(dateCal.getTimeInMillis()); - }); - - timePicker.show(getChildFragmentManager(), "timePicker"); - }); + private void onDatePicked(Long dateUtc) { + if (dateUtc == null || !isAdded()) return; + pickedDateUtc = dateUtc; + Calendar now = Calendar.getInstance(); + boolean today = + ReminderDraft.at(dateUtc, 0, 0, TimeZone.getDefault()) + == ReminderDraft.at( + MaterialDatePicker.todayInUtcMilliseconds(), + 0, + 0, + TimeZone.getDefault()); + int defaultHour = today ? now.get(Calendar.HOUR_OF_DAY) : 9; + int defaultMinute = today ? now.get(Calendar.MINUTE) + 1 : 0; + if (defaultMinute > 59) { + defaultMinute = 0; + defaultHour = Math.min(23, defaultHour + 1); + } - datePicker.show(getChildFragmentManager(), "datePicker"); + MaterialTimePicker timePicker = + new MaterialTimePicker.Builder() + .setTimeFormat(TimeFormat.CLOCK_24H) + .setHour(defaultHour) + .setMinute(defaultMinute) + .build(); + timePicker.addOnPositiveButtonClickListener(v -> onTimePicked(timePicker)); + timePicker.show(getChildFragmentManager(), TAG_TIME_PICKER); + } + + private void onTimePicked(@NonNull MaterialTimePicker timePicker) { + Long day = pickedDateUtc; + if (day == null || !isAdded()) return; + long time = + ReminderDraft.at( + day, timePicker.getHour(), timePicker.getMinute(), TimeZone.getDefault()); + if (time <= System.currentTimeMillis()) { + showPastTimeError(time); + return; + } + pickedDateUtc = null; + applyPreset(time); + } + + /** Says in the sheet itself, where it stays in sight, that the chosen time has passed. */ + private void showPastTimeError(long time) { + SimpleDateFormat fmt = new SimpleDateFormat("d MMM yyyy, HH:mm", Locale.getDefault()); + pastTimeError.setText(getString(R.string.reminder_time_passed, fmt.format(new Date(time)))); + pastTimeError.setVisibility(View.VISIBLE); + pastTimeError.announceForAccessibility(pastTimeError.getText()); } private void showRepeatSection() { @@ -276,23 +383,86 @@ private void updateTimeDisplay() { selectedTimeCard.setVisibility(View.VISIBLE); } - private void setRepeatChip(ReminderRepeat repeat) { - int chipId = - switch (repeat) { - case DAILY -> R.id.chipDaily; - case WEEKLY -> R.id.chipWeekly; - case MONTHLY -> R.id.chipMonthly; - default -> R.id.chipNone; - }; - repeatChips.check(chipId); + /** + * Selects the chip for a rule: a preset when one says the same, otherwise Custom showing the + * rule itself, "Every 3 hours". + */ + private void showRepeatRule(@NonNull RepeatRule rule) { + int preset = presetChipFor(rule); + if (preset != 0) { + repeatChips.check(preset); + return; + } + customRule = RepeatRule.every(rule.getInterval(), rule.getUnit(), null); + String summary = RepeatRuleFormatter.summary(requireContext(), customRule); + chipCustom.setText(summary); + chipCustom.setContentDescription(getString(R.string.reminder_repeat_custom_cd, summary)); + repeatChips.check(R.id.chipCustom); + } + + private static int presetChipFor(@NonNull RepeatRule rule) { + if (!rule.isRepeating()) return R.id.chipNone; + if (rule.getInterval() != 1) return 0; + return switch (rule.getUnit()) { + case DAYS -> R.id.chipDaily; + case WEEKS -> R.id.chipWeekly; + case MONTHS -> R.id.chipMonthly; + default -> 0; + }; } - private ReminderRepeat getSelectedRepeat() { - int checkedId = repeatChips.getCheckedChipId(); - if (checkedId == R.id.chipDaily) return ReminderRepeat.DAILY; - if (checkedId == R.id.chipWeekly) return ReminderRepeat.WEEKLY; - if (checkedId == R.id.chipMonthly) return ReminderRepeat.MONTHLY; - return ReminderRepeat.NONE; + @NonNull + private static RepeatRule presetRule(int chipId) { + if (chipId == R.id.chipDaily) return RepeatRule.every(1, RepeatRule.Unit.DAYS, null); + if (chipId == R.id.chipWeekly) return RepeatRule.every(1, RepeatRule.Unit.WEEKS, null); + if (chipId == R.id.chipMonthly) return RepeatRule.every(1, RepeatRule.Unit.MONTHS, null); + return RepeatRule.NONE; + } + + private void openCustomRepeat() { + if (getChildFragmentManager().findFragmentByTag(CustomRepeatDialog.TAG) != null) return; + RepeatRule current = customRule != null ? customRule : presetRule(lastPresetChipId); + CustomRepeatDialog.newInstance( + current.isRepeating() ? current.getInterval() : 2, + current.isRepeating() ? current.getUnit() : RepeatRule.Unit.DAYS) + .show(getChildFragmentManager(), CustomRepeatDialog.TAG); + } + + private void onCustomRepeat(@NonNull Bundle result) { + if (repeatChips == null) return; + if (!result.getBoolean(CustomRepeatDialog.RESULT_APPLIED, false)) { + // Cancelled: back to what was selected before, unless Custom already held a rule. + if (customRule == null) repeatChips.check(lastPresetChipId); + return; + } + RepeatRule.Unit unit; + try { + unit = RepeatRule.Unit.valueOf(result.getString(CustomRepeatDialog.RESULT_UNIT, "")); + } catch (IllegalArgumentException e) { + return; + } + showRepeatRule( + RepeatRule.every(result.getInt(CustomRepeatDialog.RESULT_INTERVAL, 1), unit, null)); + } + + /** + * The chosen rule, anchored at the chosen time. Re-saving without changing the time or the + * repeat keeps the stored anchor: a monthly reminder for the 31st that now waits for the 30th + * would otherwise move to the 30th for good. + */ + @NonNull + private RepeatRule selectedRule(long time) { + int checked = repeatChips.getCheckedChipId(); + RepeatRule rule = + checked == R.id.chipCustom && customRule != null ? customRule : presetRule(checked); + if (!rule.isRepeating()) return RepeatRule.NONE; + boolean unchanged = + originalTime != null + && originalTime == time + && originalRule.isRepeating() + && originalRule.getInterval() == rule.getInterval() + && originalRule.getUnit() == rule.getUnit(); + return rule.withAnchor(unchanged ? originalRule.anchorOr(time) : time); } private int intervalMinutesFromChipId(int chipId) { @@ -315,17 +485,14 @@ private void setIntervalChip(int minutes) { } private void checkPermissionsAndSave() { - if (selectedTime == null || selectedTime <= System.currentTimeMillis()) { + if (selectedTime == null) { Toast.makeText(requireContext(), R.string.reminder_past_time_error, Toast.LENGTH_SHORT) .show(); return; } - - if (!ReminderManager.canScheduleExact(requireContext())) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - Intent intent = new Intent(Settings.ACTION_REQUEST_SCHEDULE_EXACT_ALARM); - startActivity(intent); - } + if (selectedTime <= System.currentTimeMillis()) { + // The time passed while the sheet was open. + showPastTimeError(selectedTime); return; } @@ -344,9 +511,8 @@ private void checkPermissionsAndSave() { private void saveReminder() { if (selectedTime == null) return; - selectedRepeat = getSelectedRepeat(); final long time = selectedTime; - final String repeat = selectedRepeat.name(); + final String repeat = selectedRule(time).serialize(); disposables.add( dataManager @@ -364,7 +530,13 @@ private void saveReminder() { tempNote.setReminderTime(time); tempNote.setReminderRepeat(repeat); tempNote.setReminderIntervalMinutes(selectedIntervalMinutes); - ReminderManager.scheduleReminder(requireContext(), tempNote); + // Redefined: a snooze or repeating notification of the old + // reminder no longer applies. + ReminderManager.cancelReminder(requireContext(), noteId); + boolean exact = + ReminderManager.scheduleReminder( + requireContext(), tempNote); + if (!exact) askForExactAlarms(); Bundle result = new Bundle(); result.putBoolean("hasReminder", true); @@ -397,6 +569,22 @@ private void deleteReminder() { e -> Log.e(TAG, "delete failed", e))); } + /** + * The reminder is saved and armed, but without "Alarms & reminders" it may ring late: say so + * and open the system switch for this app. Granting it re-arms every reminder exactly. + */ + private void askForExactAlarms() { + Intent settings = ReminderManager.exactAlarmSettingsIntent(requireContext()); + Toast.makeText(requireContext(), R.string.reminder_exact_alarm_needed, Toast.LENGTH_LONG) + .show(); + if (settings == null) return; + try { + startActivity(settings); + } catch (android.content.ActivityNotFoundException e) { + Log.w(TAG, "no exact alarm settings screen", e); + } + } + private void showPermissionDenied() { Toast.makeText(requireContext(), R.string.reminder_permission_denied, Toast.LENGTH_SHORT) .show(); diff --git a/app/src/main/java/com/pasich/mynotes/ui/view/dialogs/RestoreVersionDialog.java b/app/src/main/java/com/pasich/mynotes/ui/view/dialogs/RestoreVersionDialog.java new file mode 100644 index 00000000..24a99c66 --- /dev/null +++ b/app/src/main/java/com/pasich/mynotes/ui/view/dialogs/RestoreVersionDialog.java @@ -0,0 +1,55 @@ +package com.pasich.mynotes.ui.view.dialogs; + +import android.app.Dialog; +import android.os.Bundle; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.fragment.app.DialogFragment; +import com.google.android.material.dialog.MaterialAlertDialogBuilder; +import com.pasich.mynotes.R; + +/** + * "Restore this version?" A dialog fragment rather than a plain dialog, so it stays open through a + * rotation and its answer reaches the recreated screen. + */ +public class RestoreVersionDialog extends DialogFragment { + + public static final String TAG = "RestoreVersionDialog"; + private static final String ARG_VERSION_ID = "versionId"; + + /** The screen that restores the version. */ + public interface Host { + void onRestoreConfirmed(long versionId); + } + + @NonNull + public static RestoreVersionDialog newInstance(long versionId) { + RestoreVersionDialog dialog = new RestoreVersionDialog(); + Bundle args = new Bundle(); + args.putLong(ARG_VERSION_ID, versionId); + dialog.setArguments(args); + return dialog; + } + + public long versionId() { + return requireArguments().getLong(ARG_VERSION_ID); + } + + @NonNull + @Override + public Dialog onCreateDialog(@Nullable Bundle savedInstanceState) { + return new MaterialAlertDialogBuilder(requireContext()) + .setIcon(R.drawable.ic_history) + .setTitle(R.string.version_restore_title) + .setMessage(R.string.version_restore_message) + .setNegativeButton(R.string.cancel, null) + .setPositiveButton( + R.string.version_restore, + (dialog, which) -> { + if (requireActivity() instanceof Host host) { + host.onRestoreConfirmed(versionId()); + } + }) + .create(); + } +} diff --git a/app/src/main/java/com/pasich/mynotes/ui/view/dialogs/main/ViewOptionsDialog.java b/app/src/main/java/com/pasich/mynotes/ui/view/dialogs/main/ViewOptionsDialog.java new file mode 100644 index 00000000..c1e4f5f4 --- /dev/null +++ b/app/src/main/java/com/pasich/mynotes/ui/view/dialogs/main/ViewOptionsDialog.java @@ -0,0 +1,159 @@ +package com.pasich.mynotes.ui.view.dialogs.main; + +import android.content.Context; +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import com.google.android.material.bottomsheet.BottomSheetDialog; +import com.pasich.mynotes.R; +import com.pasich.mynotes.base.dialog.BaseDialogBottomSheets; +import com.pasich.mynotes.databinding.DialogViewOptionsBinding; +import com.pasich.mynotes.utils.constants.settings.SortParam; +import com.pasich.mynotes.utils.tool.FormatListTool; + +/** + * Bottom sheet with the main screen's view options: the sort order of the notes and the list or + * grid layout. Every choice is reported to the host at once and the sheet stays open, so both can + * be changed in one visit. + * + *

The sheet holds no preferences of its own: it shows what it was opened with and the host + * ({@link Listener}, the activity) saves and applies the choice, so it survives recreation. + */ +public class ViewOptionsDialog extends BaseDialogBottomSheets { + + public static final String TAG = "ViewOptionsDialog"; + + private static final String ARG_SORT = "sort"; + private static final String ARG_FORMAT = "format"; + + private DialogViewOptionsBinding binding; + @Nullable private Listener listener; + private String sort; + private int format; + + public static ViewOptionsDialog newInstance(String sort, int format) { + ViewOptionsDialog dialog = new ViewOptionsDialog(); + Bundle args = new Bundle(); + args.putString(ARG_SORT, sort); + args.putInt(ARG_FORMAT, format); + dialog.setArguments(args); + return dialog; + } + + @Override + public void onAttach(@NonNull Context context) { + super.onAttach(context); + if (context instanceof Listener l) listener = l; + } + + @Override + public void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + Bundle state = savedInstanceState != null ? savedInstanceState : getArguments(); + sort = state != null ? state.getString(ARG_SORT, SortParam.DataSort) : SortParam.DataSort; + format = + state != null + ? state.getInt(ARG_FORMAT, FormatListTool.FORMAT_LIST) + : FormatListTool.FORMAT_LIST; + } + + @Nullable + @Override + public View onCreateView( + @NonNull LayoutInflater inflater, + @Nullable ViewGroup container, + @Nullable Bundle savedInstanceState) { + binding = DialogViewOptionsBinding.inflate(inflater, container, false); + bindState(); + initListeners(); + return binding.getRoot(); + } + + @Override + public void setState(BottomSheetDialog dialog) { + super.setState(dialog); + } + + private void bindState() { + binding.sortGroup.check(sortButtonFor(sort)); + bindCustomHint(); + binding.layoutGroup.check( + format == FormatListTool.FORMAT_GRID ? R.id.layoutGrid : R.id.layoutList); + } + + @Override + public void initListeners() { + binding.sortGroup.setOnCheckedChangeListener( + (group, checkedId) -> { + String chosen = sortFor(checkedId); + if (chosen.equals(sort)) return; + sort = chosen; + bindCustomHint(); + if (listener != null) listener.onViewSortSelected(chosen); + }); + binding.layoutGroup.addOnButtonCheckedListener( + (group, checkedId, isChecked) -> { + if (!isChecked) return; + int chosen = + checkedId == R.id.layoutGrid + ? FormatListTool.FORMAT_GRID + : FormatListTool.FORMAT_LIST; + if (chosen == format) return; + format = chosen; + if (listener != null) listener.onViewLayoutSelected(chosen); + }); + } + + private void bindCustomHint() { + binding.sortCustomHint.setVisibility( + SortParam.Custom.equals(sort) ? View.VISIBLE : View.GONE); + } + + static int sortButtonFor(String sort) { + if (SortParam.Custom.equals(sort)) return R.id.sortCustom; + return SortParam.DataReserve.equals(sort) ? R.id.sortOldest : R.id.sortNewest; + } + + static String sortFor(int checkedId) { + if (checkedId == R.id.sortCustom) return SortParam.Custom; + return checkedId == R.id.sortOldest ? SortParam.DataReserve : SortParam.DataSort; + } + + @Override + public void onSaveInstanceState(@NonNull Bundle outState) { + super.onSaveInstanceState(outState); + outState.putString(ARG_SORT, sort); + outState.putInt(ARG_FORMAT, format); + } + + @Override + public void onDestroyView() { + if (binding != null) { + binding.sortGroup.setOnCheckedChangeListener(null); + binding.layoutGroup.clearOnButtonCheckedListeners(); + } + binding = null; + super.onDestroyView(); + } + + @Override + public void onDetach() { + listener = null; + super.onDetach(); + } + + /** Receives the choices made in the sheet; implemented by the host activity. */ + public interface Listener { + /** {@code sortParam} is one of the notes values of {@link SortParam}. */ + void onViewSortSelected(String sortParam); + + /** + * {@code format} is {@link FormatListTool#FORMAT_LIST} or {@link + * FormatListTool#FORMAT_GRID}. + */ + void onViewLayoutSelected(int format); + } +} diff --git a/app/src/main/java/com/pasich/mynotes/ui/view/dialogs/settings/ChoiceDialog.java b/app/src/main/java/com/pasich/mynotes/ui/view/dialogs/settings/ChoiceDialog.java new file mode 100644 index 00000000..bf6f7d4c --- /dev/null +++ b/app/src/main/java/com/pasich/mynotes/ui/view/dialogs/settings/ChoiceDialog.java @@ -0,0 +1,92 @@ +package com.pasich.mynotes.ui.view.dialogs.settings; + +import android.content.Context; +import android.graphics.Typeface; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.BaseAdapter; +import android.widget.ImageView; +import android.widget.TextView; +import androidx.annotation.DrawableRes; +import androidx.annotation.NonNull; +import androidx.annotation.StringRes; +import androidx.core.view.ViewCompat; +import com.google.android.material.dialog.MaterialAlertDialogBuilder; +import com.pasich.mynotes.R; + +/** + * A settings choice in the style of the theme mode dialog: each option has an icon, a name and a + * line saying what it does, and the current one is checked (and announced as selected). + */ +public final class ChoiceDialog { + + /** One option. */ + public record Option(@DrawableRes int icon, @StringRes int name, @StringRes int summary) {} + + public interface Callback { + void onSelected(int which); + } + + private ChoiceDialog() {} + + public static void show( + @NonNull Context ctx, + @StringRes int title, + @NonNull Option[] options, + int checked, + @NonNull Callback callback) { + BaseAdapter adapter = + new BaseAdapter() { + @Override + public int getCount() { + return options.length; + } + + @Override + public Option getItem(int position) { + return options[position]; + } + + @Override + public long getItemId(int position) { + return position; + } + + @Override + public View getView(int position, View convertView, ViewGroup parent) { + View view = + convertView != null + ? convertView + : LayoutInflater.from(parent.getContext()) + .inflate( + R.layout.item_choice_dialog, parent, false); + Option option = options[position]; + boolean selected = position == checked; + + ImageView icon = view.findViewById(R.id.choiceIcon); + TextView name = view.findViewById(R.id.choiceName); + TextView summary = view.findViewById(R.id.choiceSummary); + ImageView check = view.findViewById(R.id.choiceSelected); + + icon.setImageResource(option.icon()); + name.setText(option.name()); + name.setTypeface( + Typeface.create( + name.getTypeface(), + selected ? Typeface.BOLD : Typeface.NORMAL)); + summary.setText(option.summary()); + check.setVisibility(selected ? View.VISIBLE : View.INVISIBLE); + ViewCompat.setStateDescription( + view, selected ? ctx.getString(R.string.choice_selected) : null); + return view; + } + }; + + new MaterialAlertDialogBuilder(ctx, R.style.Theme_MyNotes_Dialog) + .setTitle(title) + .setAdapter(adapter, (dialog, which) -> callback.onSelected(which)) + .setNegativeButton(R.string.cancel, null) + .show(); + } +} diff --git a/app/src/main/java/com/pasich/mynotes/ui/view/fragment/settings/InteractionSettingsFragment.java b/app/src/main/java/com/pasich/mynotes/ui/view/fragment/settings/InteractionSettingsFragment.java index 47592da4..51523315 100644 --- a/app/src/main/java/com/pasich/mynotes/ui/view/fragment/settings/InteractionSettingsFragment.java +++ b/app/src/main/java/com/pasich/mynotes/ui/view/fragment/settings/InteractionSettingsFragment.java @@ -11,9 +11,12 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.fragment.app.Fragment; +import com.pasich.mynotes.R; +import com.pasich.mynotes.cache.NoteOpeningPreferences; import com.pasich.mynotes.cache.ThemePreferencesCache; import com.pasich.mynotes.databinding.FragmentInteractionSettingsBinding; import com.pasich.mynotes.ui.controllers.mainActivity.RedrawThemeController; +import com.pasich.mynotes.ui.view.dialogs.settings.ChoiceDialog; import dagger.hilt.android.AndroidEntryPoint; import javax.inject.Inject; @@ -21,6 +24,45 @@ public class InteractionSettingsFragment extends Fragment { @Inject ThemePreferencesCache themePreferencesCache; + @Inject NoteOpeningPreferences noteOpeningPreferences; + + private static final ChoiceDialog.Option[] MODE_OPTIONS = { + new ChoiceDialog.Option( + R.drawable.ic_auto_mode, + R.string.note_opening_mode_auto, + R.string.note_opening_mode_auto_summary), + new ChoiceDialog.Option( + R.drawable.ic_read, + R.string.note_opening_mode_read, + R.string.note_opening_mode_read_summary), + new ChoiceDialog.Option( + R.drawable.ic_edit, + R.string.note_opening_mode_edit, + R.string.note_opening_mode_edit_summary) + }; + + /** In the order of {@link #MODE_OPTIONS}. */ + private static final NoteOpeningPreferences.OpenMode[] MODES = { + NoteOpeningPreferences.OpenMode.AUTO, + NoteOpeningPreferences.OpenMode.READ, + NoteOpeningPreferences.OpenMode.EDIT + }; + + private static final ChoiceDialog.Option[] POSITION_OPTIONS = { + new ChoiceDialog.Option( + R.drawable.ic_history, + R.string.note_opening_position_last, + R.string.note_opening_position_last_summary), + new ChoiceDialog.Option( + R.drawable.ic_vertical_align_top, + R.string.note_opening_position_start, + R.string.note_opening_position_start_summary) + }; + + /** In the order of {@link #POSITION_OPTIONS}. */ + private static final NoteOpeningPreferences.OpenPosition[] POSITIONS = { + NoteOpeningPreferences.OpenPosition.LAST, NoteOpeningPreferences.OpenPosition.START + }; private FragmentInteractionSettingsBinding binding; @@ -45,6 +87,47 @@ private void initViews() { binding.screenProtection.setChecked(themePreferencesCache.isScreenProtectionEnabled()); binding.extendedEditor.setChecked(themePreferencesCache.isExtendedEditorEnabled()); binding.setExtendedDetailsVisible(false); + binding.doubleTapToEdit.setChecked(noteOpeningPreferences.isDoubleTapToEdit()); + updateOpeningValues(); + } + + /** Shows the chosen opening mode and position under their titles. */ + private void updateOpeningValues() { + int mode = indexOf(MODES, noteOpeningPreferences.getOpenMode()); + binding.openModeValue.setText(MODE_OPTIONS[mode].name()); + int position = indexOf(POSITIONS, noteOpeningPreferences.getOpenPosition()); + binding.openPositionValue.setText(POSITION_OPTIONS[position].name()); + } + + private static int indexOf(T[] values, T value) { + for (int i = 0; i < values.length; i++) { + if (values[i] == value) return i; + } + return 0; + } + + private void chooseOpenMode() { + ChoiceDialog.show( + requireContext(), + R.string.note_opening_mode, + MODE_OPTIONS, + indexOf(MODES, noteOpeningPreferences.getOpenMode()), + which -> { + noteOpeningPreferences.setOpenMode(MODES[which]); + if (binding != null) updateOpeningValues(); + }); + } + + private void chooseOpenPosition() { + ChoiceDialog.show( + requireContext(), + R.string.note_opening_position, + POSITION_OPTIONS, + indexOf(POSITIONS, noteOpeningPreferences.getOpenPosition()), + which -> { + noteOpeningPreferences.setOpenPosition(POSITIONS[which]); + if (binding != null) updateOpeningValues(); + }); } private void initListeners() { @@ -58,6 +141,10 @@ private void initListeners() { startActivity( new Intent(Intent.ACTION_VIEW, Uri.parse(SEND_FEEDBACK_EDITOR)))); binding.detailsExtended.setOnClickListener(v -> toggleDetails()); + binding.openModeCard.setOnClickListener(v -> chooseOpenMode()); + binding.openPositionCard.setOnClickListener(v -> chooseOpenPosition()); + binding.doubleTapToEdit.setOnCheckedChangeListener( + (buttonView, isChecked) -> noteOpeningPreferences.setDoubleTapToEdit(isChecked)); } public void toggleDetails() { @@ -78,6 +165,25 @@ public void updateThemeColors() { binding.extendedEditorDescription, binding.extendedEditor, requireContext()); + RedrawThemeController.styleCardBlock( + binding.openModeCard, + binding.openModeTitle, + binding.openModeValue, + null, + requireContext()); + RedrawThemeController.styleTextVariant(binding.openModeDescription, requireContext()); + RedrawThemeController.styleCardBlock( + binding.openPositionCard, + binding.openPositionTitle, + binding.openPositionValue, + null, + requireContext()); + RedrawThemeController.styleCardBlock( + binding.doubleTapCard, + null, + binding.doubleTapDescription, + binding.doubleTapToEdit, + requireContext()); } @Override diff --git a/app/src/main/java/com/pasich/mynotes/ui/view/widgets/EditorKeyboardBar.java b/app/src/main/java/com/pasich/mynotes/ui/view/widgets/EditorKeyboardBar.java new file mode 100644 index 00000000..19fc95bb --- /dev/null +++ b/app/src/main/java/com/pasich/mynotes/ui/view/widgets/EditorKeyboardBar.java @@ -0,0 +1,241 @@ +package com.pasich.mynotes.ui.view.widgets; + +import android.content.Context; +import android.util.AttributeSet; +import android.view.View; +import android.view.ViewGroup; +import android.widget.LinearLayout; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.VisibleForTesting; +import androidx.core.graphics.Insets; +import androidx.core.view.ViewCompat; +import androidx.core.view.WindowInsetsAnimationCompat; +import androidx.core.view.WindowInsetsCompat; +import com.google.android.material.button.MaterialButton; +import com.google.android.material.color.MaterialColors; +import com.pasich.mynotes.R; +import com.pasich.mynotes.utils.editor.KeyboardBarState; +import java.util.List; + +/** + * The bar docked above the on-screen keyboard in both note editors: Undo and Redo at the start, + * Hide keyboard at the end, room for further actions between them. + * + *

It is shown only while the note is edited and the keyboard is up (see {@link + * KeyboardBarState}). The host places it at the bottom of its root, which must not be padded at the + * bottom, and passes every window insets dispatch to {@link #onWindowInsets}; the bar then sits on + * top of the keyboard, or of the navigation bar, and the host keeps its content clear of it by + * {@link #getReservedHeight()}. + * + *

While the keyboard slides in or out the bar is laid out where it ends and translated to follow + * the keyboard frame by frame, so it moves with the keyboard instead of jumping ahead of it. With + * animations turned off the keyboard has no animation and neither has the bar. + */ +public class EditorKeyboardBar extends LinearLayout { + + /** What the bar's buttons ask the editor to do. */ + public interface Actions { + void onUndo(); + + void onRedo(); + + void onHideKeyboard(); + } + + private final KeyboardBarState state = new KeyboardBarState(); + private View row; + private MaterialButton undoButton; + private MaterialButton redoButton; + private MaterialButton hideButton; + @Nullable private Actions actions; + + // Distance from the bottom of the parent to the bottom of the bar when it rests: the keyboard, + // or the navigation bar while the keyboard is down. + private int restingOffset = 0; + // A keyboard animation is running; hiding the bar waits for its end so the bar goes down with + // the keyboard rather than vanishing above it. + private boolean imeAnimating = false; + + public EditorKeyboardBar(Context context) { + super(context); + init(context); + } + + public EditorKeyboardBar(Context context, @Nullable AttributeSet attrs) { + super(context, attrs); + init(context); + } + + public EditorKeyboardBar(Context context, @Nullable AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + init(context); + } + + private void init(Context context) { + setOrientation(VERTICAL); + inflate(context, R.layout.view_editor_keyboard_bar, this); + row = findViewById(R.id.keyboardBarRow); + undoButton = findViewById(R.id.keyboardBarUndo); + redoButton = findViewById(R.id.keyboardBarRedo); + hideButton = findViewById(R.id.keyboardBarHide); + undoButton.setOnClickListener( + v -> { + if (actions != null) actions.onUndo(); + }); + redoButton.setOnClickListener( + v -> { + if (actions != null) actions.onRedo(); + }); + hideButton.setOnClickListener( + v -> { + if (actions != null) actions.onHideKeyboard(); + }); + setBackgroundColor( + MaterialColors.getColor( + this, com.google.android.material.R.attr.colorSurfaceContainer)); + // Touches on the bar's empty space must not reach the note underneath. + setClickable(true); + setVisibility(GONE); + ViewCompat.setWindowInsetsAnimationCallback(this, new FollowKeyboard()); + applyHistory(); + } + + public void setActions(@Nullable Actions actions) { + this.actions = actions; + } + + /** The note is being edited (true) or read. */ + public void setEditing(boolean editing) { + boolean visibilityChanged = state.setEditing(editing); + applyHistory(); + if (visibilityChanged) { + applyVisibility(); + // The host gives the content back, or takes it, in its insets listener. + ViewCompat.requestApplyInsets(this); + } + } + + /** Enables Undo and Redo according to the editor's history. */ + public void setHistoryState(boolean canUndo, boolean canRedo) { + if (state.setHistory(canUndo, canRedo)) applyHistory(); + } + + /** + * Takes the window insets the host has just been given: places the bar on top of the keyboard + * or the navigation bar and shows or hides it with the keyboard. Call it before reading {@link + * #getReservedHeight()}. + */ + public void onWindowInsets(@NonNull WindowInsetsCompat insets) { + Insets bars = + insets.getInsets( + WindowInsetsCompat.Type.systemBars() + | WindowInsetsCompat.Type.displayCutout()); + Insets ime = insets.getInsets(WindowInsetsCompat.Type.ime()); + restingOffset = Math.max(ime.bottom, bars.bottom); + + ViewGroup.LayoutParams params = getLayoutParams(); + if (params instanceof MarginLayoutParams + && ((MarginLayoutParams) params).bottomMargin != restingOffset) { + ((MarginLayoutParams) params).bottomMargin = restingOffset; + setLayoutParams(params); + } + // In landscape the navigation bar or a cutout may sit at a side; the buttons stay clear of + // it while the background still runs edge to edge. + int side = getResources().getDimensionPixelSize(R.dimen.editor_keyboard_bar_side); + row.setPadding(bars.left + side, 0, bars.right + side, 0); + + if (state.setImeVisible(insets.isVisible(WindowInsetsCompat.Type.ime()))) { + applyVisibility(); + } + } + + /** + * Height the host keeps free above the keyboard so the bar never covers the caret line: the + * bar's height while it is shown, otherwise 0. + */ + public int getReservedHeight() { + if (!state.isShown()) return 0; + return getResources().getDimensionPixelSize(R.dimen.editor_keyboard_bar_row_height) + + getResources().getDimensionPixelSize(R.dimen.editor_keyboard_bar_divider); + } + + /** Whether the bar is meant to be on screen (it may still be finishing its way out). */ + public boolean isBarShown() { + return state.isShown(); + } + + @VisibleForTesting + KeyboardBarState getState() { + return state; + } + + private void applyHistory() { + undoButton.setEnabled(state.isUndoEnabled()); + redoButton.setEnabled(state.isRedoEnabled()); + } + + private void applyVisibility() { + if (state.isShown()) { + setVisibility(VISIBLE); + } else if (!imeAnimating) { + setTranslationY(0f); + setVisibility(GONE); + } + } + + /** + * Keeps the bar on top of the keyboard while it animates. The bar is laid out at the keyboard's + * final position as soon as the animation starts, so it is translated by the distance between + * that and the keyboard's current top. + */ + private final class FollowKeyboard extends WindowInsetsAnimationCompat.Callback { + + // Where the bar was drawn when the animation was prepared, measured from the bottom. + private int startOffset; + + FollowKeyboard() { + super(DISPATCH_MODE_CONTINUE_ON_SUBTREE); + } + + @Override + public void onPrepare(@NonNull WindowInsetsAnimationCompat animation) { + if (!isIme(animation)) return; + imeAnimating = true; + startOffset = restingOffset - Math.round(getTranslationY()); + } + + @NonNull + @Override + public WindowInsetsAnimationCompat.BoundsCompat onStart( + @NonNull WindowInsetsAnimationCompat animation, + @NonNull WindowInsetsAnimationCompat.BoundsCompat bounds) { + if (isIme(animation)) setTranslationY(restingOffset - startOffset); + return bounds; + } + + @NonNull + @Override + public WindowInsetsCompat onProgress( + @NonNull WindowInsetsCompat insets, + @NonNull List runningAnimations) { + if (!imeAnimating) return insets; + int ime = insets.getInsets(WindowInsetsCompat.Type.ime()).bottom; + int bars = insets.getInsets(WindowInsetsCompat.Type.systemBars()).bottom; + setTranslationY(restingOffset - Math.max(ime, bars)); + return insets; + } + + @Override + public void onEnd(@NonNull WindowInsetsAnimationCompat animation) { + if (!isIme(animation)) return; + imeAnimating = false; + setTranslationY(0f); + applyVisibility(); + } + + private boolean isIme(WindowInsetsAnimationCompat animation) { + return (animation.getTypeMask() & WindowInsetsCompat.Type.ime()) != 0; + } + } +} diff --git a/app/src/main/java/com/pasich/mynotes/ui/view/widgets/QuickActionsRow.java b/app/src/main/java/com/pasich/mynotes/ui/view/widgets/QuickActionsRow.java new file mode 100644 index 00000000..3dee6674 --- /dev/null +++ b/app/src/main/java/com/pasich/mynotes/ui/view/widgets/QuickActionsRow.java @@ -0,0 +1,125 @@ +package com.pasich.mynotes.ui.view.widgets; + +import android.content.Context; +import android.text.TextPaint; +import android.util.AttributeSet; +import android.view.View; +import android.view.ViewGroup; +import android.widget.TextView; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.VisibleForTesting; +import java.util.ArrayList; +import java.util.List; +import java.util.function.ToDoubleFunction; + +/** + * The More sheet's quick actions: an icon over a label each, side by side. When a label would need + * more than {@link #MAX_LINES} lines, or a word is wider than its cell (a long word in a large + * font), the actions go two to a row instead, so a label is never cut off or broken inside a word. + */ +public class QuickActionsRow extends ViewGroup { + + static final int MAX_LINES = 2; + + private final List shown = new ArrayList<>(); + private int columns = 1; + private final List rowHeights = new ArrayList<>(); + + public QuickActionsRow(Context context, @Nullable AttributeSet attrs) { + super(context, attrs); + } + + @Override + protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { + int width = MeasureSpec.getSize(widthMeasureSpec); + int inner = width - getPaddingLeft() - getPaddingRight(); + shown.clear(); + for (int i = 0; i < getChildCount(); i++) { + View child = getChildAt(i); + if (child.getVisibility() != GONE) shown.add(child); + } + columns = Math.max(1, shown.size()); + if (columns > 2 && !allFit(inner / columns)) columns = 2; + int cell = inner / columns; + rowHeights.clear(); + int height = getPaddingTop() + getPaddingBottom(); + for (int i = 0; i < shown.size(); i += columns) { + int rowHeight = 0; + for (int j = i; j < Math.min(shown.size(), i + columns); j++) { + View child = shown.get(j); + child.measure( + MeasureSpec.makeMeasureSpec(cell, MeasureSpec.EXACTLY), + MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED)); + rowHeight = Math.max(rowHeight, child.getMeasuredHeight()); + } + rowHeights.add(rowHeight); + height += rowHeight; + } + setMeasuredDimension(width, resolveSize(height, heightMeasureSpec)); + } + + @Override + protected void onLayout(boolean changed, int l, int t, int r, int b) { + int inner = getWidth() - getPaddingLeft() - getPaddingRight(); + int cell = inner / columns; + int y = getPaddingTop(); + for (int row = 0; row < rowHeights.size(); row++) { + int first = row * columns; + int count = Math.min(columns, shown.size() - first); + // A last row that is not full is centred. + int x = getPaddingLeft() + (inner - count * cell) / 2; + for (int j = first; j < first + count; j++) { + View child = shown.get(j); + child.layout(x, y, x + cell, y + rowHeights.get(row)); + x += cell; + } + y += rowHeights.get(row); + } + } + + private boolean allFit(int cell) { + for (View child : shown) { + TextView label = labelOf(child); + if (label == null) continue; + int width = cell - child.getPaddingLeft() - child.getPaddingRight(); + TextPaint paint = label.getPaint(); + if (!fits(label.getText().toString(), width, paint::measureText)) return false; + } + return true; + } + + @Nullable + private static TextView labelOf(View child) { + if (child instanceof TextView text) return text; + if (child instanceof ViewGroup group) { + for (int i = 0; i < group.getChildCount(); i++) { + if (group.getChildAt(i) instanceof TextView text) return text; + } + } + return null; + } + + /** + * Whether {@code text}, wrapped only between words, takes at most {@link #MAX_LINES} lines of + * {@code width} with no word wider than a line. + */ + @VisibleForTesting + public static boolean fits( + @NonNull String text, int width, @NonNull ToDoubleFunction measure) { + String[] words = text.trim().split("\\s+"); + int lines = 1; + String line = ""; + for (String word : words) { + if (measure.applyAsDouble(word) > width) return false; + String joined = line.isEmpty() ? word : line + " " + word; + if (measure.applyAsDouble(joined) <= width) { + line = joined; + } else { + lines++; + line = word; + } + } + return lines <= MAX_LINES; + } +} diff --git a/app/src/main/java/com/pasich/mynotes/utils/UpdateChecker.java b/app/src/main/java/com/pasich/mynotes/utils/UpdateChecker.java index 2264dc86..92e431d7 100644 --- a/app/src/main/java/com/pasich/mynotes/utils/UpdateChecker.java +++ b/app/src/main/java/com/pasich/mynotes/utils/UpdateChecker.java @@ -61,6 +61,18 @@ public void markVersionAsRead() { cache.setLastKnownVersion(currentVersion); } + /** + * The one-time "Meet Encly" introduction is shown on the first start of an updated version, + * like the changelog it precedes, and never again once seen. + */ + public boolean shouldShowMeetEncly() { + return hasNewVersion() && !cache.isMeetEnclyShown(); + } + + public void markMeetEnclyShown() { + cache.setMeetEnclyShown(); + } + /** Initialize version check (should be called on the first launch) */ public void initializeVersionCheck() { String lastKnownVersion = cache.getLastKnownVersion(); diff --git a/app/src/main/java/com/pasich/mynotes/utils/adapters/notes/NoteAdapter.java b/app/src/main/java/com/pasich/mynotes/utils/adapters/notes/NoteAdapter.java index 040ea1c9..ed060d2a 100644 --- a/app/src/main/java/com/pasich/mynotes/utils/adapters/notes/NoteAdapter.java +++ b/app/src/main/java/com/pasich/mynotes/utils/adapters/notes/NoteAdapter.java @@ -1,36 +1,164 @@ package com.pasich.mynotes.utils.adapters.notes; import android.view.LayoutInflater; +import android.view.View; import android.view.ViewGroup; import androidx.annotation.NonNull; -import androidx.recyclerview.widget.ListAdapter; +import androidx.annotation.Nullable; +import androidx.core.view.ViewCompat; +import androidx.recyclerview.widget.AdapterListUpdateCallback; +import androidx.recyclerview.widget.AsyncDifferConfig; +import androidx.recyclerview.widget.AsyncListDiffer; +import androidx.recyclerview.widget.ListUpdateCallback; import androidx.recyclerview.widget.RecyclerView; +import com.pasich.mynotes.R; import com.pasich.mynotes.data.model.Note; import com.pasich.mynotes.databinding.ItemNoteBinding; import com.pasich.mynotes.ui.controllers.SelectionController; import com.pasich.mynotes.utils.recycler.diffutil.NoteDiff; import com.pasich.mynotes.utils.recycler.payloads.NotePayloads; import dagger.hilt.android.scopes.ActivityScoped; +import java.util.ArrayList; +import java.util.Collections; import java.util.List; import javax.inject.Inject; -/** RecyclerView adapter for displaying note cards with selection support. */ +/** + * RecyclerView adapter for displaying note cards with selection support. + * + *

Also carries a drag in the custom order: while a card is being dragged the adapter shows its + * own working copy of the list, moved one step at a time as {@link + * androidx.recyclerview.widget.ItemTouchHelper} asks, and the dropped order is then handed to the + * differ without dispatching it again — the cards are already where it says they are, and a second + * round of moves would send them sliding over one another. + */ @ActivityScoped -public class NoteAdapter extends ListAdapter { +public class NoteAdapter extends RecyclerView.Adapter { + + /** Moves a note one place through the accessibility actions of its card. */ + public interface ReorderListener { + /** {@code step} is -1 to move the note up the list, +1 to move it down. */ + void onMoveRequested(@NonNull Note note, int step); + } + + private final ListUpdateCallback adapterCallback = new AdapterListUpdateCallback(this); + private boolean muted; + private final AsyncListDiffer differ = + new AsyncListDiffer<>( + new ListUpdateCallback() { + @Override + public void onInserted(int position, int count) { + if (!muted) adapterCallback.onInserted(position, count); + } + + @Override + public void onRemoved(int position, int count) { + if (!muted) adapterCallback.onRemoved(position, count); + } + + @Override + public void onMoved(int fromPosition, int toPosition) { + if (!muted) adapterCallback.onMoved(fromPosition, toPosition); + } + + @Override + public void onChanged(int position, int count, @Nullable Object payload) { + if (!muted) adapterCallback.onChanged(position, count, payload); + } + }, + new AsyncDifferConfig.Builder<>(new NoteDiff()).build()); private OnItemClickListener listener; private SelectionController selectionController; + @Nullable private ReorderListener reorderListener; + + /** The order shown while a card is dragged; null otherwise. */ + @Nullable private List dragOrder; @Inject - public NoteAdapter() { - super(new NoteDiff()); - } + public NoteAdapter() {} /** Attaches a SelectionController to enable per-item selection state rendering. */ public void setSelectionController(SelectionController controller) { this.selectionController = controller; } + public void submitList(@Nullable List list) { + differ.submitList(list); + } + + public void submitList(@Nullable List list, @Nullable Runnable commitCallback) { + differ.submitList(list, commitCallback); + } + + /** The list on screen, including the working order of a drag in progress. */ + @NonNull + public List getCurrentList() { + return dragOrder != null + ? Collections.unmodifiableList(dragOrder) + : differ.getCurrentList(); + } + + @NonNull + Note getItem(int position) { + return dragOrder != null ? dragOrder.get(position) : differ.getCurrentList().get(position); + } + + @Override + public int getItemCount() { + return dragOrder != null ? dragOrder.size() : differ.getCurrentList().size(); + } + + /** Whether a drag is in progress. */ + public boolean isDragging() { + return dragOrder != null; + } + + /** Starts a drag over the list as it is now shown. */ + public void beginDrag() { + if (dragOrder == null) dragOrder = new ArrayList<>(differ.getCurrentList()); + } + + /** Moves one card one or more places during a drag. */ + public void moveDuringDrag(int from, int to) { + if (dragOrder == null + || from == to + || from < 0 + || to < 0 + || from >= dragOrder.size() + || to >= dragOrder.size()) return; + dragOrder.add(to, dragOrder.remove(from)); + notifyItemMoved(from, to); + } + + /** + * Ends a drag, keeping the order it left on screen. + * + * @param committed runs once the list behind the adapter is the dropped order. + */ + public void endDrag(@NonNull Runnable committed) { + List dropped = dragOrder; + if (dropped == null) { + committed.run(); + return; + } + muted = true; + differ.submitList( + new ArrayList<>(dropped), + () -> { + muted = false; + dragOrder = null; + committed.run(); + }); + } + + /** Turns on the "move up" and "move down" accessibility actions of every card, or off. */ + public void setReorderListener(@Nullable ReorderListener listener) { + if (reorderListener == listener) return; + reorderListener = listener; + notifyItemRangeChanged(0, getItemCount(), NotePayloads.PAYLOAD_REORDER); + } + @NonNull @Override public NoteHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { @@ -42,10 +170,14 @@ public NoteHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { @Override public void onBindViewHolder( @NonNull NoteHolder holder, int position, @NonNull List payloads) { - if (!payloads.isEmpty() && payloads.contains(NotePayloads.PAYLOAD_SELECTION)) { - holder.bindSelectionState(getItem(position)); + if (!payloads.isEmpty() + && (payloads.contains(NotePayloads.PAYLOAD_SELECTION) + || payloads.contains(NotePayloads.PAYLOAD_REORDER))) { + Note note = getItem(position); + if (payloads.contains(NotePayloads.PAYLOAD_SELECTION)) holder.bindSelectionState(note); + if (payloads.contains(NotePayloads.PAYLOAD_REORDER)) holder.bindReorderActions(note); } else { - super.onBindViewHolder(holder, position, payloads); + onBindViewHolder(holder, position); } } @@ -58,9 +190,11 @@ public void setOnItemClickListener(OnItemClickListener listener) { this.listener = listener; } - class NoteHolder extends RecyclerView.ViewHolder { + public class NoteHolder extends RecyclerView.ViewHolder { final ItemNoteBinding binding; + private int moveUpAction = View.NO_ID; + private int moveDownAction = View.NO_ID; NoteHolder(ItemNoteBinding b) { super(b.getRoot()); @@ -72,6 +206,7 @@ void bind(Note note) { binding.executePendingBindings(); bindSelectionState(note); + bindReorderActions(note); binding.getRoot() .setOnClickListener(v -> listener.onClick(getBindingAdapterPosition(), note)); @@ -97,5 +232,60 @@ void bindSelectionState(Note note) { binding.executePendingBindings(); } + + /** + * A handle on the cards that can be dragged, in the custom order. The first line of text + * keeps clear of it. + */ + void bindDragHandle(boolean shown) { + binding.dragHandle.setVisibility(shown ? View.VISIBLE : View.GONE); + int clear = + shown + ? itemView.getResources() + .getDimensionPixelSize(R.dimen.note_drag_handle_clearance) + : 0; + boolean titled = binding.nameNote.getVisibility() == View.VISIBLE; + setEndPadding(binding.nameNote, titled ? clear : 0); + setEndPadding(binding.previewNote, titled ? 0 : clear); + } + + private void setEndPadding(View view, int end) { + view.setPaddingRelative( + view.getPaddingStart(), view.getPaddingTop(), end, view.getPaddingBottom()); + } + + /** + * In the custom order a card can also be moved without dragging, which is the only way for + * someone using TalkBack or a switch. + */ + void bindReorderActions(Note note) { + if (moveUpAction != View.NO_ID) { + ViewCompat.removeAccessibilityAction(itemView, moveUpAction); + moveUpAction = View.NO_ID; + } + if (moveDownAction != View.NO_ID) { + ViewCompat.removeAccessibilityAction(itemView, moveDownAction); + moveDownAction = View.NO_ID; + } + ReorderListener reorder = reorderListener; + bindDragHandle(reorder != null && !note.isPinned()); + if (reorder == null) return; + moveUpAction = + ViewCompat.addAccessibilityAction( + itemView, + itemView.getContext().getString(R.string.note_move_up), + (view, arguments) -> { + reorder.onMoveRequested(note, -1); + return true; + }); + moveDownAction = + ViewCompat.addAccessibilityAction( + itemView, + itemView.getContext().getString(R.string.note_move_down), + (view, arguments) -> { + reorder.onMoveRequested(note, 1); + return true; + }); + } } } diff --git a/app/src/main/java/com/pasich/mynotes/utils/adapters/searchAdapter/SearchNotesAdapter.java b/app/src/main/java/com/pasich/mynotes/utils/adapters/searchAdapter/SearchNotesAdapter.java index 386b5c76..124db2e2 100644 --- a/app/src/main/java/com/pasich/mynotes/utils/adapters/searchAdapter/SearchNotesAdapter.java +++ b/app/src/main/java/com/pasich/mynotes/utils/adapters/searchAdapter/SearchNotesAdapter.java @@ -1,32 +1,44 @@ package com.pasich.mynotes.utils.adapters.searchAdapter; -import android.annotation.SuppressLint; +import android.text.SpannableString; +import android.text.Spanned; +import android.text.style.BackgroundColorSpan; +import android.text.style.ForegroundColorSpan; import android.view.LayoutInflater; import android.view.ViewGroup; +import android.widget.TextView; import androidx.annotation.NonNull; import androidx.recyclerview.widget.DiffUtil; import androidx.recyclerview.widget.ListAdapter; import androidx.recyclerview.widget.RecyclerView; +import com.google.android.material.color.MaterialColors; import com.pasich.mynotes.data.model.Note; import com.pasich.mynotes.databinding.ItemResultBinding; +import com.pasich.mynotes.utils.search.NoteSearchRanker; +import com.pasich.mynotes.utils.search.SearchHit; +import com.pasich.mynotes.utils.search.SearchText; import dagger.hilt.android.scopes.ActivityScoped; import javax.inject.Inject; -/** RecyclerView adapter for displaying note search results. */ +/** + * RecyclerView adapter for ranked note search results. The part of the title and of the preview + * that matched the query is marked with the theme's primary container colour. + */ @ActivityScoped -public class SearchNotesAdapter extends ListAdapter { +public class SearchNotesAdapter extends ListAdapter { - private static final DiffUtil.ItemCallback DIFF_CALLBACK = + private static final DiffUtil.ItemCallback DIFF_CALLBACK = new DiffUtil.ItemCallback<>() { @Override - public boolean areItemsTheSame(@NonNull Note oldItem, @NonNull Note newItem) { - return oldItem.getId() == newItem.getId(); + public boolean areItemsTheSame( + @NonNull SearchHit oldItem, @NonNull SearchHit newItem) { + return oldItem.note().getId() == newItem.note().getId(); } - @SuppressLint("DiffUtilEquals") @Override - public boolean areContentsTheSame(@NonNull Note oldItem, @NonNull Note newItem) { - return oldItem.equals(newItem); + public boolean areContentsTheSame( + @NonNull SearchHit oldItem, @NonNull SearchHit newItem) { + return oldItem.sameContentAs(newItem); } }; @@ -65,13 +77,46 @@ public ViewHolder(ItemResultBinding binding, SetItemClickListener clickListener) this.clickListener = clickListener; } - public void bind(Note note) { + public void bind(SearchHit hit) { + Note note = hit.note(); binding.setNote(note); binding.executePendingBindings(); + // A one-letter query never searched the text, so marking a letter there would mislead. + boolean bodySearched = hit.query().length() >= NoteSearchRanker.MIN_BODY_QUERY_LENGTH; + setHighlighted(binding.nameNote, note.getTitle(), hit.query()); + setHighlighted( + binding.previewNote, note.getValuePreview(), bodySearched ? hit.query() : null); + if (clickListener != null) { itemView.setOnClickListener(v -> clickListener.onClick(note, binding.itemNote)); } } + + private static void setHighlighted(TextView view, String text, String query) { + int[] range = query == null ? null : SearchText.locate(text, query); + if (range == null) { + view.setText(text); + return; + } + SpannableString marked = new SpannableString(text); + int background = + MaterialColors.getColor( + view, com.google.android.material.R.attr.colorPrimaryContainer); + int foreground = + MaterialColors.getColor( + view, com.google.android.material.R.attr.colorOnPrimaryContainer); + marked.setSpan( + new BackgroundColorSpan(background), + range[0], + range[1], + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + marked.setSpan( + new ForegroundColorSpan(foreground), + range[0], + range[1], + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + view.setText(marked); + } } } diff --git a/app/src/main/java/com/pasich/mynotes/utils/constants/DatabaseConstants.java b/app/src/main/java/com/pasich/mynotes/utils/constants/DatabaseConstants.java index 80068834..929b848a 100644 --- a/app/src/main/java/com/pasich/mynotes/utils/constants/DatabaseConstants.java +++ b/app/src/main/java/com/pasich/mynotes/utils/constants/DatabaseConstants.java @@ -3,5 +3,5 @@ public class DatabaseConstants { public static final String DB_NAME = "MyNotes.db"; - public static final int DB_VERSION = 22; + public static final int DB_VERSION = 23; } diff --git a/app/src/main/java/com/pasich/mynotes/utils/constants/settings/PreferencesConfig.java b/app/src/main/java/com/pasich/mynotes/utils/constants/settings/PreferencesConfig.java index d1184d9d..cc7c1215 100644 --- a/app/src/main/java/com/pasich/mynotes/utils/constants/settings/PreferencesConfig.java +++ b/app/src/main/java/com/pasich/mynotes/utils/constants/settings/PreferencesConfig.java @@ -42,6 +42,15 @@ public static int normalizeNoteTextSize(int size) { public static final boolean ARGUMENT_DEFAULT_EXTENDED_EDITOR_VALUE = true; // default new user // LastKnownVersion public static final String ARGUMENT_PREFERENCE_LAST_KNOWN_VERSION = "lastKnownVersion"; + // One-time "Meet Encly" introduction (pasichDev/MyNotes#167) + public static final String ARGUMENT_PREFERENCE_MEET_ENCLY_SHOWN = "meetEnclyShown"; + + /** Whether the notes list uses the custom order; device-local, never backed up or synced. */ + public static final String ARGUMENT_PREFERENCE_CUSTOM_ORDER = "notesCustomOrder"; + + /** Whether the one-time hint pointing a long press at the custom order was shown. */ + public static final String ARGUMENT_PREFERENCE_CUSTOM_ORDER_HINT_SHOWN = "customOrderHintShown"; + // ThemeMode public static final String ARGUMENT_PREFERENCE_THEME_MODE = "themeMode"; public static final int ARGUMENT_DEFAULT_THEME_MODE_VALUE = diff --git a/app/src/main/java/com/pasich/mynotes/utils/constants/settings/SortParam.java b/app/src/main/java/com/pasich/mynotes/utils/constants/settings/SortParam.java index ed0e8f86..c497af6f 100644 --- a/app/src/main/java/com/pasich/mynotes/utils/constants/settings/SortParam.java +++ b/app/src/main/java/com/pasich/mynotes/utils/constants/settings/SortParam.java @@ -3,6 +3,13 @@ public class SortParam { public static final String DataSort = "DataSort"; public static final String DataReserve = "DataReserve"; + + /** + * The user's own order. Kept on this device only: the synced setting stays DataSort or + * DataReserve, which every version of the app understands. + */ + public static final String Custom = "CustomSort"; + public static final String TagsPositionSort = "TagsPositionSort"; public static final String TagsCreationDateSort = "TagsCreationDateSort"; } diff --git a/app/src/main/java/com/pasich/mynotes/utils/databinding/NoteBindingAdapters.java b/app/src/main/java/com/pasich/mynotes/utils/databinding/NoteBindingAdapters.java index a603b95a..1c71c282 100644 --- a/app/src/main/java/com/pasich/mynotes/utils/databinding/NoteBindingAdapters.java +++ b/app/src/main/java/com/pasich/mynotes/utils/databinding/NoteBindingAdapters.java @@ -10,6 +10,7 @@ import com.pasich.mynotes.data.model.Note; import com.pasich.mynotes.extendedEditor.models.ParsedNote; import com.pasich.mynotes.utils.FormattedDataUtil; +import com.pasich.mynotes.utils.reminder.RepeatRuleFormatter; import java.text.SimpleDateFormat; import java.util.Calendar; import java.util.Locale; @@ -169,6 +170,9 @@ public static void setReminderText(TextView textView, Note note) { text = dateFmt.format(rem.getTime()); } + String repeat = RepeatRuleFormatter.summary(ctx, note.getReminderRepeat()); + if (repeat != null) text = ctx.getString(R.string.reminder_time_with_repeat, text, repeat); + textView.setText(text); textView.setVisibility(View.VISIBLE); } diff --git a/app/src/main/java/com/pasich/mynotes/utils/editor/EditableLinkMovementMethod.java b/app/src/main/java/com/pasich/mynotes/utils/editor/EditableLinkMovementMethod.java new file mode 100644 index 00000000..febc6ce8 --- /dev/null +++ b/app/src/main/java/com/pasich/mynotes/utils/editor/EditableLinkMovementMethod.java @@ -0,0 +1,88 @@ +package com.pasich.mynotes.utils.editor; + +import android.content.ActivityNotFoundException; +import android.text.Layout; +import android.text.Selection; +import android.text.Spannable; +import android.text.method.ArrowKeyMovementMethod; +import android.text.method.MovementMethod; +import android.text.style.URLSpan; +import android.util.Log; +import android.view.MotionEvent; +import android.view.ViewConfiguration; +import android.widget.TextView; + +/** + * Movement for an editable text that also contains links. + * + *

It is the movement every EditText already uses, with one addition: a tap on a link opens it. + * The previous {@code LinkMovementMethod} subclass was built for read-only text: it cleared the + * selection whenever the field took focus, so the caret fell back to offset 0 and the next + * keystroke landed at the very top of the note. Dragging, long presses and taps outside links are + * left entirely to the platform, so selection, handles and the caret behave as in any field. + */ +public final class EditableLinkMovementMethod extends ArrowKeyMovementMethod { + + private static final String TAG = "EditableLinkMovement"; + private static EditableLinkMovementMethod instance; + + private float downX; + private float downY; + private long downTime; + + private EditableLinkMovementMethod() {} + + public static MovementMethod getInstance() { + if (instance == null) instance = new EditableLinkMovementMethod(); + return instance; + } + + @Override + public boolean onTouchEvent(TextView widget, Spannable buffer, MotionEvent event) { + int action = event.getActionMasked(); + if (action == MotionEvent.ACTION_DOWN) { + downX = event.getX(); + downY = event.getY(); + downTime = event.getEventTime(); + } else if (action == MotionEvent.ACTION_UP && isTap(widget, event)) { + URLSpan link = linkAt(widget, buffer, event); + if (link != null + && EditorCursor.shouldOpenLink( + Selection.getSelectionStart(buffer), + Selection.getSelectionEnd(buffer), + buffer.getSpanStart(link), + buffer.getSpanEnd(link))) { + try { + link.onClick(widget); + return true; + } catch (ActivityNotFoundException e) { + Log.w(TAG, "No app can open this link", e); + } + } + } + return super.onTouchEvent(widget, buffer, event); + } + + private boolean isTap(TextView widget, MotionEvent up) { + ViewConfiguration config = ViewConfiguration.get(widget.getContext()); + return EditorCursor.isTap( + up.getX() - downX, + up.getY() - downY, + up.getEventTime() - downTime, + config.getScaledTouchSlop(), + ViewConfiguration.getLongPressTimeout()); + } + + /** The link under the pointer, ignoring the empty space after a line's last character. */ + private static URLSpan linkAt(TextView widget, Spannable buffer, MotionEvent event) { + Layout layout = widget.getLayout(); + if (layout == null) return null; + int x = (int) event.getX() - widget.getTotalPaddingLeft() + widget.getScrollX(); + int y = (int) event.getY() - widget.getTotalPaddingTop() + widget.getScrollY(); + int line = layout.getLineForVertical(y); + if (x < layout.getLineLeft(line) || x > layout.getLineRight(line)) return null; + int offset = layout.getOffsetForHorizontal(line, x); + URLSpan[] links = buffer.getSpans(offset, offset, URLSpan.class); + return links.length == 0 ? null : links[0]; + } +} diff --git a/app/src/main/java/com/pasich/mynotes/utils/editor/EditorCursor.java b/app/src/main/java/com/pasich/mynotes/utils/editor/EditorCursor.java new file mode 100644 index 00000000..ea064dbd --- /dev/null +++ b/app/src/main/java/com/pasich/mynotes/utils/editor/EditorCursor.java @@ -0,0 +1,82 @@ +package com.pasich.mynotes.utils.editor; + +/** + * Pure decisions about where the simple editor's caret goes, kept free of Android types so they are + * covered by plain JVM tests. + */ +public final class EditorCursor { + + /** No offset known. */ + public static final int NONE = -1; + + private EditorCursor() {} + + /** Clamps {@code offset} into {@code [0, length]}. */ + public static int clamp(int offset, int length) { + if (length <= 0) return 0; + return Math.max(0, Math.min(offset, length)); + } + + /** + * Picks the selection to apply when editing is switched on. + * + *

A selection restored from a previous instance (rotation, process restore) wins, clamped to + * the text that is actually there. Otherwise the caret goes where the reader is looking: the + * end of the note when its end is on screen, so a short note is continued, or the start of the + * first visible line, so a long note stays exactly where it was instead of jumping to its end. + * + * @param restoredStart restored selection start, or {@link #NONE}. + * @param restoredEnd restored selection end, or {@link #NONE}. + * @param firstVisibleOffset text offset at the top of the viewport, or {@link #NONE}. + * @param lastVisibleOffset text offset at the bottom of the viewport, or {@link #NONE}. + * @param length current text length. + * @return {@code {start, end}}, both within {@code [0, length]}, start ≤ end. + */ + public static int[] activationSelection( + int restoredStart, + int restoredEnd, + int firstVisibleOffset, + int lastVisibleOffset, + int length) { + if (restoredStart >= 0) { + int start = clamp(restoredStart, length); + int end = restoredEnd >= 0 ? clamp(restoredEnd, length) : start; + return new int[] {Math.min(start, end), Math.max(start, end)}; + } + if (firstVisibleOffset < 0 || lastVisibleOffset < 0 || lastVisibleOffset >= length) { + return new int[] {length, length}; + } + int caret = clamp(firstVisibleOffset, length); + return new int[] {caret, caret}; + } + + /** + * Whether a pointer that went down and up is a tap rather than a drag or a long press. + * + * @param dx horizontal travel in pixels. + * @param dy vertical travel in pixels. + * @param durationMs time between down and up. + * @param touchSlop the platform touch slop in pixels. + * @param longPressTimeoutMs the platform long-press timeout. + */ + public static boolean isTap( + float dx, float dy, long durationMs, int touchSlop, long longPressTimeoutMs) { + if (durationMs < 0 || durationMs >= longPressTimeoutMs) return false; + return dx * dx + dy * dy <= (float) touchSlop * touchSlop; + } + + /** + * Whether a tap on a link should open it. It should not when the caret already sits inside the + * link: the user put it there on purpose to edit the address. + */ + public static boolean shouldOpenLink( + int selectionStart, int selectionEnd, int linkStart, int linkEnd) { + if (linkStart < 0 || linkEnd <= linkStart) return false; + boolean caretInside = + selectionStart >= 0 + && selectionStart == selectionEnd + && selectionStart > linkStart + && selectionStart < linkEnd; + return !caretInside; + } +} diff --git a/app/src/main/java/com/pasich/mynotes/utils/editor/KeyboardBarState.java b/app/src/main/java/com/pasich/mynotes/utils/editor/KeyboardBarState.java new file mode 100644 index 00000000..dec9f84c --- /dev/null +++ b/app/src/main/java/com/pasich/mynotes/utils/editor/KeyboardBarState.java @@ -0,0 +1,61 @@ +package com.pasich.mynotes.utils.editor; + +/** + * What the bar above the keyboard shows, kept free of Android types so it is covered by plain JVM + * tests. + * + *

The bar is there only while the note is being edited and the on-screen keyboard is up: in + * reading mode there is nothing to undo, and with a hardware keyboard (no on-screen keyboard) + * Ctrl+Z and Ctrl+Y do the job and the bar would only take space from the note. Undo and Redo are + * enabled while the editor's history has a step to take back or to apply again. + */ +public final class KeyboardBarState { + + private boolean editing; + private boolean imeVisible; + private boolean canUndo; + private boolean canRedo; + + /** Sets whether the note is being edited; returns whether the bar's visibility changed. */ + public boolean setEditing(boolean editing) { + boolean wasShown = isShown(); + this.editing = editing; + return wasShown != isShown(); + } + + /** + * Sets whether the on-screen keyboard is visible; returns whether the bar's visibility changed. + */ + public boolean setImeVisible(boolean imeVisible) { + boolean wasShown = isShown(); + this.imeVisible = imeVisible; + return wasShown != isShown(); + } + + /** Sets what the editor's history allows; returns whether either button changed. */ + public boolean setHistory(boolean canUndo, boolean canRedo) { + boolean changed = this.canUndo != canUndo || this.canRedo != canRedo; + this.canUndo = canUndo; + this.canRedo = canRedo; + return changed; + } + + public boolean isEditing() { + return editing; + } + + /** The bar sits above the keyboard only while the note is edited with the keyboard up. */ + public boolean isShown() { + return editing && imeVisible; + } + + /** Undo is offered while editing and there is a step to take back. */ + public boolean isUndoEnabled() { + return editing && canUndo; + } + + /** Redo is offered while editing and there is an undone step to apply again. */ + public boolean isRedoEnabled() { + return editing && canRedo; + } +} diff --git a/app/src/main/java/com/pasich/mynotes/utils/editor/KeyboardRequest.java b/app/src/main/java/com/pasich/mynotes/utils/editor/KeyboardRequest.java new file mode 100644 index 00000000..fc9dccc1 --- /dev/null +++ b/app/src/main/java/com/pasich/mynotes/utils/editor/KeyboardRequest.java @@ -0,0 +1,81 @@ +package com.pasich.mynotes.utils.editor; + +/** + * Brings up the on-screen keyboard for a view that cannot take it at once, kept free of Android + * types so it is covered by plain JVM tests. + * + *

A request made while the window has no focus yet (the screen is still opening) or before the + * focused view is connected to the input method (a WebView whose page has just placed its caret) is + * dropped by the system without a word. Worse, a keyboard asked for before the page's editable + * field has focus can come up attached to nothing: it is on screen, but there is no caret and + * typing goes nowhere. So a request waits for window focus, then for the editable field to hold the + * focus, then connects the keyboard to it afresh, and is repeated a few times until the keyboard is + * actually visible. + */ +public final class KeyboardRequest { + + /** What to do next. */ + public enum Step { + /** Nothing is asked, or the keyboard is up on the editable field: stop. */ + DONE, + /** The window has no focus: try again when it gets it. */ + WAIT_FOR_WINDOW_FOCUS, + /** + * The editable field has no focus yet: focus the view and its caret, ask for nothing and + * check again after {@link #RETRY_MS}. + */ + FOCUS_AND_RETRY, + /** + * The editable field has just got the focus: connect the input method to it anew (a + * keyboard already on screen may still be attached to nothing), ask for the keyboard and + * check again after {@link #RETRY_MS}. + */ + CONNECT_AND_SHOW, + /** Ask for the keyboard and check again after {@link #RETRY_MS}. */ + SHOW_AND_RETRY + } + + public static final int ATTEMPTS = 20; + public static final long RETRY_MS = 100; + + private int attemptsLeft; + private boolean connected; + + /** Starts a new request; any earlier one is replaced. */ + public void start() { + attemptsLeft = ATTEMPTS; + connected = false; + } + + /** Drops the request, for example when the note leaves editing mode. */ + public void cancel() { + attemptsLeft = 0; + } + + public boolean isPending() { + return attemptsLeft > 0; + } + + /** + * The next step, given whether the keyboard is visible, the window has focus and the editable + * field in the view holds the focus. + */ + public Step next(boolean keyboardVisible, boolean windowFocused, boolean editorFocused) { + if (attemptsLeft <= 0) return Step.DONE; + if (!windowFocused) return Step.WAIT_FOR_WINDOW_FOCUS; + attemptsLeft--; + if (!editorFocused) { + connected = false; + return Step.FOCUS_AND_RETRY; + } + if (!connected) { + connected = true; + return Step.CONNECT_AND_SHOW; + } + if (keyboardVisible) { + attemptsLeft = 0; + return Step.DONE; + } + return Step.SHOW_AND_RETRY; + } +} diff --git a/app/src/main/java/com/pasich/mynotes/utils/editor/NoteViewState.java b/app/src/main/java/com/pasich/mynotes/utils/editor/NoteViewState.java new file mode 100644 index 00000000..c2c8feaf --- /dev/null +++ b/app/src/main/java/com/pasich/mynotes/utils/editor/NoteViewState.java @@ -0,0 +1,106 @@ +package com.pasich.mynotes.utils.editor; + +import androidx.annotation.Nullable; +import com.google.gson.annotations.SerializedName; + +/** + * Where a note was last being read or edited on this device. One entry per note, with a section for + * each editor, because the two describe a position differently: the simple editor by text offsets, + * the extended one by Editor.js block ids. + * + *

It is device-local on purpose and never part of the {@code Note} entity: the note syncs, and a + * caret position from one device means nothing on another. + */ +public final class NoteViewState { + + /** When the entry was last written, for the least-recently-used limit. */ + @SerializedName("u") + public long usedAt; + + @SerializedName("s") + @Nullable + public Simple simple; + + @SerializedName("x") + @Nullable + public Extended extended; + + /** Position in the simple editor, by text offset, with enough context to find it again. */ + public static final class Simple { + /** Selection when the note was left while editing; {@link EditorCursor#NONE} otherwise. */ + @SerializedName("ss") + public int selectionStart = EditorCursor.NONE; + + @SerializedName("se") + public int selectionEnd = EditorCursor.NONE; + + /** Offset of the text at the top of the viewport; NONE while the title was on screen. */ + @SerializedName("to") + public int topOffset = EditorCursor.NONE; + + @SerializedName("sy") + public int scrollY; + + /** Scroll position as a share of the scrollable height, 0..1. */ + @SerializedName("sr") + public float scrollRatio; + + /** Length and hash of the text the offsets refer to. */ + @SerializedName("len") + public int contentLength; + + @SerializedName("h") + @Nullable + public String contentHash; + + /** Text just before and just after the caret, to find it again after an edit. */ + @SerializedName("cb") + @Nullable + public String caretBefore; + + @SerializedName("ca") + @Nullable + public String caretAfter; + + /** The same around the top of the viewport. */ + @SerializedName("tb") + @Nullable + public String topBefore; + + @SerializedName("ta") + @Nullable + public String topAfter; + } + + /** Position in the extended editor, by Editor.js block. */ + public static final class Extended { + /** Block holding the caret and the caret's character offset in it; null when reading. */ + @SerializedName("cb") + @Nullable + public String caretBlockId; + + @SerializedName("co") + public int caretOffset; + + /** Index of the block's input the caret is in (a list block has one per item). */ + @SerializedName("ci") + public int caretInput; + + /** Block at the top of the viewport and its distance from the top, in CSS pixels. */ + @SerializedName("tb") + @Nullable + public String topBlockId; + + @SerializedName("ti") + public int topBlockIndex = -1; + + @SerializedName("tp") + public int topOffsetPx; + + @SerializedName("st") + public int scrollTop; + + @SerializedName("sr") + public float scrollRatio; + } +} diff --git a/app/src/main/java/com/pasich/mynotes/utils/editor/NoteViewStateStore.java b/app/src/main/java/com/pasich/mynotes/utils/editor/NoteViewStateStore.java new file mode 100644 index 00000000..26c88c94 --- /dev/null +++ b/app/src/main/java/com/pasich/mynotes/utils/editor/NoteViewStateStore.java @@ -0,0 +1,163 @@ +package com.pasich.mynotes.utils.editor; + +import android.content.Context; +import android.content.SharedPreferences; +import android.util.Log; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import com.google.gson.Gson; +import com.google.gson.JsonParseException; +import dagger.hilt.android.qualifiers.ApplicationContext; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import javax.inject.Inject; +import javax.inject.Singleton; + +/** + * Keeps each note's last reading or editing position on this device ({@link NoteViewState}), so a + * reopened note continues where it was left. + * + *

Stored in a preferences file of its own, outside the settings that backups and Drive sync + * carry: a position only makes sense on the device where it was taken. Only the {@link + * #MAX_ENTRIES} most recently used notes are kept. Every instance reads and writes the same file, + * and Android shares one in-memory copy of it per process, so instances never disagree. + */ +@Singleton +public class NoteViewStateStore { + + static final String FILE = "note_view_state"; + static final int MAX_ENTRIES = 200; + private static final String KEY_PREFIX = "n_"; + private static final String TAG = "NoteViewStateStore"; + + private final SharedPreferences prefs; + private final Gson gson = new Gson(); + private final Clock clock; + + interface Clock { + long now(); + } + + @Inject + public NoteViewStateStore(@ApplicationContext @NonNull Context context) { + this(context, System::currentTimeMillis); + } + + NoteViewStateStore(@NonNull Context context, @NonNull Clock clock) { + this.prefs = + context.getApplicationContext().getSharedPreferences(FILE, Context.MODE_PRIVATE); + this.clock = clock; + } + + /** The saved position of a note, or null when there is none (or it cannot be read). */ + @Nullable + public NoteViewState get(long noteId) { + if (noteId <= 0) return null; + return parse(prefs.getString(key(noteId), null)); + } + + /** Whether anything is saved for the note. */ + public boolean contains(long noteId) { + return noteId > 0 && prefs.contains(key(noteId)); + } + + /** Saves the simple editor's position, keeping the extended editor's. */ + public void putSimple(long noteId, @NonNull NoteViewState.Simple simple) { + if (noteId <= 0) return; + NoteViewState state = orEmpty(get(noteId)); + state.simple = simple; + write(noteId, state); + } + + /** Saves the extended editor's position, keeping the simple editor's. */ + public void putExtended(long noteId, @NonNull NoteViewState.Extended extended) { + if (noteId <= 0) return; + NoteViewState state = orEmpty(get(noteId)); + state.extended = extended; + write(noteId, state); + } + + /** Forgets a note's position; called when the note is deleted for good. */ + public void remove(long noteId) { + if (noteId <= 0) return; + prefs.edit().remove(key(noteId)).apply(); + } + + /** Forgets the positions of several notes. */ + public void removeAll(@Nullable Collection noteIds) { + if (noteIds == null || noteIds.isEmpty()) return; + SharedPreferences.Editor editor = prefs.edit(); + for (Number id : noteIds) { + if (id != null) editor.remove(key(id.longValue())); + } + editor.apply(); + } + + /** Forgets every position. */ + public void clear() { + prefs.edit().clear().apply(); + } + + private void write(long noteId, NoteViewState state) { + state.usedAt = clock.now(); + SharedPreferences.Editor editor = prefs.edit().putString(key(noteId), gson.toJson(state)); + Map all = prefs.getAll(); + // The entry being written may be new: count it before deciding what to drop. + int count = all.size() + (all.containsKey(key(noteId)) ? 0 : 1); + if (count > MAX_ENTRIES) { + Map usedAt = new HashMap<>(); + for (Map.Entry entry : all.entrySet()) { + if (entry.getKey().equals(key(noteId))) continue; + NoteViewState other = + entry.getValue() instanceof String + ? parse((String) entry.getValue()) + : null; + usedAt.put(entry.getKey(), other != null ? other.usedAt : 0L); + } + for (String stale : leastRecentlyUsed(usedAt, count - MAX_ENTRIES)) { + editor.remove(stale); + } + } + editor.apply(); + } + + /** The {@code count} keys used longest ago. */ + @NonNull + static List leastRecentlyUsed(@NonNull Map usedAt, int count) { + if (count <= 0) return Collections.emptyList(); + List> entries = new ArrayList<>(usedAt.entrySet()); + entries.sort( + (a, b) -> { + int byTime = Long.compare(a.getValue(), b.getValue()); + return byTime != 0 ? byTime : a.getKey().compareTo(b.getKey()); + }); + List keys = new ArrayList<>(); + for (int i = 0; i < Math.min(count, entries.size()); i++) { + keys.add(entries.get(i).getKey()); + } + return keys; + } + + @Nullable + private NoteViewState parse(@Nullable String json) { + if (json == null || json.isEmpty()) return null; + try { + return gson.fromJson(json, NoteViewState.class); + } catch (JsonParseException | IllegalStateException e) { + Log.w(TAG, "Dropping an unreadable position", e); + return null; + } + } + + private static NoteViewState orEmpty(@Nullable NoteViewState state) { + return state != null ? state : new NoteViewState(); + } + + private static String key(long noteId) { + return KEY_PREFIX + noteId; + } +} diff --git a/app/src/main/java/com/pasich/mynotes/utils/editor/PositionRestorer.java b/app/src/main/java/com/pasich/mynotes/utils/editor/PositionRestorer.java new file mode 100644 index 00000000..e98dbe98 --- /dev/null +++ b/app/src/main/java/com/pasich/mynotes/utils/editor/PositionRestorer.java @@ -0,0 +1,350 @@ +package com.pasich.mynotes.utils.editor; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import java.util.List; + +/** + * Decides where a reopened note should be shown, from the position saved when it was left and the + * content it has now. Free of Android types, so every case is covered by plain JVM tests. + * + *

The note may have changed in between, here or through a sync. In order of preference the + * position is restored exactly (the text is unchanged), found again by the text around it, mapped + * proportionally (a modest edit with no recognisable context left), or dropped in favour of the top + * of the note (the note was emptied or largely rewritten). A returned offset is always within the + * current text, so a selection built from it can never be out of range. + */ +public final class PositionRestorer { + + /** How much text on each side of a position is kept to find it again. */ + static final int ANCHOR_CHARS = 32; + + /** Shorter context than this matches too much to be trusted on its own. */ + static final int MIN_ANCHOR_CHARS = 8; + + /** + * A note whose length changed by more than this share since the position was saved is treated + * as rewritten: an offset mapped proportionally into it would point at unrelated text. + */ + static final float MAX_LENGTH_CHANGE = 0.5f; + + /** How a position was recovered. */ + public enum Match { + /** The content is unchanged; the position is exactly where it was. */ + EXACT, + /** The content changed; the position was found again by its surrounding text. */ + ANCHOR, + /** The content changed a little; the position was mapped proportionally and clamped. */ + CLAMPED, + /** Nothing worth restoring: the note opens at its start. */ + TOP + } + + private PositionRestorer() {} + + // --------------------------------------------------------------------------------------- + // Simple editor + // --------------------------------------------------------------------------------------- + + /** Where the simple editor should put the caret and the top of the viewport. */ + public static final class SimpleTarget { + @NonNull public final Match match; + + /** Selection to apply, or {@link EditorCursor#NONE}; within the text when set. */ + public final int selectionStart; + + public final int selectionEnd; + + /** Offset to bring to the top of the viewport, or {@link EditorCursor#NONE}. */ + public final int topOffset; + + SimpleTarget(@NonNull Match match, int selectionStart, int selectionEnd, int topOffset) { + this.match = match; + this.selectionStart = selectionStart; + this.selectionEnd = selectionEnd; + this.topOffset = topOffset; + } + + public boolean hasSelection() { + return selectionStart >= 0; + } + + static SimpleTarget top() { + return new SimpleTarget( + Match.TOP, EditorCursor.NONE, EditorCursor.NONE, EditorCursor.NONE); + } + } + + /** + * Describes the simple editor's position for saving. + * + * @param text the note's text as shown. + * @param selectionStart selection start while editing, or {@link EditorCursor#NONE}. + * @param selectionEnd selection end while editing, or {@link EditorCursor#NONE}. + * @param topOffset offset at the top of the viewport, or NONE while the title is on screen. + * @param scrollY scroll position in pixels. + * @param scrollRatio scroll position as a share of the scrollable height. + */ + @NonNull + public static NoteViewState.Simple captureSimple( + @Nullable String text, + int selectionStart, + int selectionEnd, + int topOffset, + int scrollY, + float scrollRatio) { + String value = text != null ? text : ""; + int length = value.length(); + NoteViewState.Simple state = new NoteViewState.Simple(); + state.contentLength = length; + state.contentHash = hash(value); + state.scrollY = Math.max(0, scrollY); + state.scrollRatio = clampRatio(scrollRatio); + + if (selectionStart >= 0) { + int start = EditorCursor.clamp(selectionStart, length); + int end = selectionEnd >= 0 ? EditorCursor.clamp(selectionEnd, length) : start; + state.selectionStart = Math.min(start, end); + state.selectionEnd = Math.max(start, end); + state.caretBefore = before(value, state.selectionStart); + state.caretAfter = after(value, state.selectionStart); + } + if (topOffset >= 0) { + state.topOffset = EditorCursor.clamp(topOffset, length); + state.topBefore = before(value, state.topOffset); + state.topAfter = after(value, state.topOffset); + } + return state; + } + + /** + * Where to put the simple editor's caret and viewport for {@code text}, given what was saved. + */ + @NonNull + public static SimpleTarget restoreSimple( + @Nullable NoteViewState.Simple saved, @Nullable String text) { + if (saved == null || text == null || text.isEmpty()) return SimpleTarget.top(); + boolean hadSelection = saved.selectionStart >= 0; + boolean hadTop = saved.topOffset >= 0; + if (!hadSelection && !hadTop) return SimpleTarget.top(); + + int length = text.length(); + + if (length == saved.contentLength && hash(text).equals(saved.contentHash)) { + int start = hadSelection ? snap(text, saved.selectionStart) : EditorCursor.NONE; + int end = + hadSelection + ? snap(text, Math.max(saved.selectionStart, saved.selectionEnd)) + : EditorCursor.NONE; + int top = hadTop ? snap(text, saved.topOffset) : EditorCursor.NONE; + return new SimpleTarget(Match.EXACT, start, end, top); + } + + float scale = saved.contentLength > 0 ? (float) length / saved.contentLength : 1f; + int caret = + hadSelection + ? relocate( + text, + saved.caretBefore, + saved.caretAfter, + Math.round(saved.selectionStart * scale)) + : EditorCursor.NONE; + int top = + hadTop + ? relocate( + text, + saved.topBefore, + saved.topAfter, + Math.round(saved.topOffset * scale)) + : EditorCursor.NONE; + + if (caret >= 0 || top >= 0) { + // The old selection's text may be gone, so only the caret is put back. Without its + // own context the viewport follows the caret. + int viewport = top >= 0 ? top : (hadTop ? caret : EditorCursor.NONE); + return new SimpleTarget(Match.ANCHOR, caret, caret, viewport); + } + + if (isRewrite(saved.contentLength, length)) return SimpleTarget.top(); + + int clampedCaret = + hadSelection + ? snap(text, Math.round(saved.selectionStart * scale)) + : EditorCursor.NONE; + int clampedTop = + hadTop ? snap(text, Math.round(saved.topOffset * scale)) : EditorCursor.NONE; + return new SimpleTarget(Match.CLAMPED, clampedCaret, clampedCaret, clampedTop); + } + + // --------------------------------------------------------------------------------------- + // Extended editor + // --------------------------------------------------------------------------------------- + + /** Where the extended editor should put the caret and the top of the viewport. */ + public static final class ExtendedTarget { + @NonNull public final Match match; + + /** Block to put the caret in, or null; with its input index and character offset. */ + @Nullable public final String caretBlockId; + + public final int caretInput; + public final int caretOffset; + + /** Block to bring to the top, by id, or by index when its id is gone; else none. */ + @Nullable public final String topBlockId; + + public final int topBlockIndex; + public final int topOffsetPx; + + ExtendedTarget( + @NonNull Match match, + @Nullable String caretBlockId, + int caretInput, + int caretOffset, + @Nullable String topBlockId, + int topBlockIndex, + int topOffsetPx) { + this.match = match; + this.caretBlockId = caretBlockId; + this.caretInput = caretInput; + this.caretOffset = caretOffset; + this.topBlockId = topBlockId; + this.topBlockIndex = topBlockIndex; + this.topOffsetPx = topOffsetPx; + } + + static ExtendedTarget top() { + return new ExtendedTarget(Match.TOP, null, 0, 0, null, -1, 0); + } + } + + /** + * Where to put the extended editor's caret and viewport, given what was saved and the ids of + * the blocks the note has now. + * + *

Editor.js keeps a block's id across edits and saves, so a block that still exists is found + * by id wherever it moved. When none of the saved blocks is left, the note was replaced (or it + * is an old plain-text note, whose single block gets a new id on every render): the viewport + * then goes back to the same block index, clamped, and the caret is not restored. + */ + @NonNull + public static ExtendedTarget restoreExtended( + @Nullable NoteViewState.Extended saved, @Nullable List blockIds) { + if (saved == null || blockIds == null || blockIds.isEmpty()) return ExtendedTarget.top(); + boolean hadCaret = saved.caretBlockId != null; + boolean hadTop = saved.topBlockId != null || saved.topBlockIndex >= 0; + if (!hadCaret && !hadTop) return ExtendedTarget.top(); + + boolean caretFound = hadCaret && blockIds.contains(saved.caretBlockId); + boolean topFound = saved.topBlockId != null && blockIds.contains(saved.topBlockId); + + if (!caretFound && !topFound) { + if (saved.topBlockIndex < 0) return ExtendedTarget.top(); + int index = Math.min(saved.topBlockIndex, blockIds.size() - 1); + return new ExtendedTarget( + Match.CLAMPED, null, 0, 0, null, index, Math.max(0, saved.topOffsetPx)); + } + + String caretId = caretFound ? saved.caretBlockId : null; + int caretInput = caretFound ? Math.max(0, saved.caretInput) : 0; + int caretOffset = caretFound ? Math.max(0, saved.caretOffset) : 0; + // Without its own block the viewport is left to the caret, which scrolls itself into view. + String topId = topFound ? saved.topBlockId : null; + int topIndex = topFound ? blockIds.indexOf(saved.topBlockId) : -1; + int topPx = topFound ? saved.topOffsetPx : 0; + boolean exact = caretFound == hadCaret && topFound == (saved.topBlockId != null); + return new ExtendedTarget( + exact ? Match.EXACT : Match.ANCHOR, + caretId, + caretInput, + caretOffset, + topId, + topIndex, + topPx); + } + + // --------------------------------------------------------------------------------------- + // Helpers + // --------------------------------------------------------------------------------------- + + /** A stable 64-bit FNV-1a hash of the text, in hex. */ + @NonNull + public static String hash(@NonNull String text) { + long h = 0xcbf29ce484222325L; + for (int i = 0; i < text.length(); i++) { + char c = text.charAt(i); + h ^= (c & 0xff); + h *= 0x100000001b3L; + h ^= (c >>> 8); + h *= 0x100000001b3L; + } + return Long.toHexString(h); + } + + /** + * Finds the offset described by the text before and after it, preferring the occurrence nearest + * to {@code expected}. Returns {@link EditorCursor#NONE} when the context is gone. + */ + static int relocate( + @NonNull String text, @Nullable String before, @Nullable String after, int expected) { + String head = before != null ? before : ""; + String tail = after != null ? after : ""; + String both = head + tail; + if (both.length() >= MIN_ANCHOR_CHARS) { + int at = nearest(text, both, expected - head.length()); + if (at >= 0) return snap(text, at + head.length()); + } + if (tail.length() >= MIN_ANCHOR_CHARS) { + int at = nearest(text, tail, expected); + if (at >= 0) return snap(text, at); + } + if (head.length() >= MIN_ANCHOR_CHARS) { + int at = nearest(text, head, expected - head.length()); + if (at >= 0) return snap(text, at + head.length()); + } + return EditorCursor.NONE; + } + + private static int nearest(@NonNull String text, @NonNull String needle, int expected) { + int best = -1; + long bestDistance = Long.MAX_VALUE; + for (int at = text.indexOf(needle); at >= 0; at = text.indexOf(needle, at + 1)) { + long distance = Math.abs((long) at - expected); + if (distance < bestDistance) { + best = at; + bestDistance = distance; + } + } + return best; + } + + static boolean isRewrite(int oldLength, int newLength) { + if (oldLength <= 0) return true; + return Math.abs(newLength - oldLength) > MAX_LENGTH_CHANGE * oldLength; + } + + /** Clamps into the text and keeps an offset from splitting a surrogate pair. */ + static int snap(@NonNull String text, int offset) { + int clamped = EditorCursor.clamp(offset, text.length()); + if (clamped > 0 + && clamped < text.length() + && Character.isLowSurrogate(text.charAt(clamped)) + && Character.isHighSurrogate(text.charAt(clamped - 1))) { + return clamped - 1; + } + return clamped; + } + + private static String before(String text, int offset) { + return text.substring(Math.max(0, offset - ANCHOR_CHARS), offset); + } + + private static String after(String text, int offset) { + return text.substring(offset, Math.min(text.length(), offset + ANCHOR_CHARS)); + } + + private static float clampRatio(float ratio) { + if (Float.isNaN(ratio)) return 0f; + return Math.max(0f, Math.min(1f, ratio)); + } +} diff --git a/app/src/main/java/com/pasich/mynotes/utils/editor/RetainedEditHistory.java b/app/src/main/java/com/pasich/mynotes/utils/editor/RetainedEditHistory.java new file mode 100644 index 00000000..9f6d5cc7 --- /dev/null +++ b/app/src/main/java/com/pasich/mynotes/utils/editor/RetainedEditHistory.java @@ -0,0 +1,36 @@ +package com.pasich.mynotes.utils.editor; + +import androidx.annotation.Nullable; +import androidx.lifecycle.ViewModel; + +/** + * Carries the extended editor's undo history across a recreation of the screen, such as a rotation. + * The page lives in the WebView and is lost with the screen; it hands its history over as it + * finishes and the new page takes it over for the same note. + * + *

Kept in memory only, as a {@link ViewModel}: the history can hold many versions of a long + * note, far more than the saved-state bundle allows, and it has no value once the screen is closed. + * The page bounds what it hands over. + */ +public class RetainedEditHistory extends ViewModel { + + private long noteId; + @Nullable private String history; + + /** Keeps the history the page of note {@code noteId} handed over, replacing any older one. */ + public synchronized void put(long noteId, @Nullable String history) { + this.noteId = noteId; + this.history = history; + } + + /** + * The history kept for {@code noteId}, which is then forgotten; null when there is none, or it + * belongs to another note. + */ + @Nullable + public synchronized String take(long noteId) { + String kept = noteId > 0 && noteId == this.noteId ? history : null; + history = null; + return kept; + } +} diff --git a/app/src/main/java/com/pasich/mynotes/utils/editor/TextEditHistory.java b/app/src/main/java/com/pasich/mynotes/utils/editor/TextEditHistory.java new file mode 100644 index 00000000..ea042a4f --- /dev/null +++ b/app/src/main/java/com/pasich/mynotes/utils/editor/TextEditHistory.java @@ -0,0 +1,550 @@ +package com.pasich.mynotes.utils.editor; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Deque; +import java.util.Iterator; +import java.util.List; + +/** + * Undo and redo for the simple editor, kept free of Android types so it is covered by plain JVM + * tests. + * + *

Every change to a field is recorded as a diff: where it starts, the text it removed and the + * text it put in, with the selection before and after it. Typing is grouped into steps the way + * people think about it: characters typed in a row, a keyboard rewriting the word it is composing + * and backspacing through text all join the step before them while they follow it closely in time + * and place, and a new word starts a new step. A step only ever touches one field, so an undo never + * changes the title and the body at once. + * + *

The history is bounded in steps and in characters; the oldest steps are dropped first. + */ +public final class TextEditHistory { + + /** Field ids; a step belongs to exactly one field. */ + public static final int FIELD_TITLE = 0; + + public static final int FIELD_BODY = 1; + + /** Default limits: enough for a long editing session, small enough to never matter. */ + public static final int DEFAULT_MAX_STEPS = 200; + + public static final int DEFAULT_MAX_CHARS = 256 * 1024; + public static final long DEFAULT_MERGE_WINDOW_MS = 1500; + + /** Told when undo or redo becomes available or unavailable. */ + public interface Listener { + void onHistoryStateChanged(boolean canUndo, boolean canRedo); + } + + /** One undoable change. Immutable once it has left the top of the undo stack. */ + public static final class Step { + public final int field; + public final int start; + public final String removed; + public final String inserted; + public final int selectionBeforeStart; + public final int selectionBeforeEnd; + public final int selectionAfterStart; + public final int selectionAfterEnd; + final long time; + final boolean sealed; + + Step( + int field, + int start, + String removed, + String inserted, + int selectionBeforeStart, + int selectionBeforeEnd, + int selectionAfterStart, + int selectionAfterEnd, + long time, + boolean sealed) { + this.field = field; + this.start = start; + this.removed = removed; + this.inserted = inserted; + this.selectionBeforeStart = selectionBeforeStart; + this.selectionBeforeEnd = selectionBeforeEnd; + this.selectionAfterStart = selectionAfterStart; + this.selectionAfterEnd = selectionAfterEnd; + this.time = time; + this.sealed = sealed; + } + + Step sealed() { + if (sealed) return this; + return new Step( + field, + start, + removed, + inserted, + selectionBeforeStart, + selectionBeforeEnd, + selectionAfterStart, + selectionAfterEnd, + time, + true); + } + + int size() { + return removed.length() + inserted.length(); + } + } + + /** + * What applying an undo or redo means for a field: replace {@code [start, start + + * replaceLength)} with {@code text}, then select {@code [selectionStart, selectionEnd]}. + */ + public static final class Change { + public final int field; + public final int start; + public final int replaceLength; + public final String text; + public final int selectionStart; + public final int selectionEnd; + + Change( + int field, + int start, + int replaceLength, + String text, + int selectionStart, + int selectionEnd) { + this.field = field; + this.start = start; + this.replaceLength = replaceLength; + this.text = text; + this.selectionStart = selectionStart; + this.selectionEnd = selectionEnd; + } + + /** The field's text after this change, for callers that hold it as a string. */ + public String applyTo(String current) { + return current.substring(0, start) + text + current.substring(start + replaceLength); + } + } + + /** + * The history in a form that fits a saved-instance state: parallel arrays, oldest step first. + * {@code undoCount} steps can be undone; the rest can be redone, the next redo last. + */ + public static final class Snapshot { + public final int[] fields; + public final int[] starts; + public final String[] removed; + public final String[] inserted; + // before start, before end, after start, after end, per step. + public final int[] selections; + public final int undoCount; + // Which texts the history ends at; a restore into anything else is refused. + public final int titleHash; + public final int bodyHash; + + public Snapshot( + int[] fields, + int[] starts, + String[] removed, + String[] inserted, + int[] selections, + int undoCount, + int titleHash, + int bodyHash) { + this.fields = fields; + this.starts = starts; + this.removed = removed; + this.inserted = inserted; + this.selections = selections; + this.undoCount = undoCount; + this.titleHash = titleHash; + this.bodyHash = bodyHash; + } + + boolean isConsistent() { + int n = fields == null ? -1 : fields.length; + return n >= 0 + && starts != null + && starts.length == n + && removed != null + && removed.length == n + && inserted != null + && inserted.length == n + && selections != null + && selections.length == n * 4 + && undoCount >= 0 + && undoCount <= n; + } + } + + private final int maxSteps; + private final int maxChars; + private final long mergeWindowMs; + private final Deque undo = new ArrayDeque<>(); + private final Deque redo = new ArrayDeque<>(); + private int totalChars = 0; + private int redoChars = 0; + @Nullable private Listener listener; + private boolean lastCanUndo = false; + private boolean lastCanRedo = false; + + public TextEditHistory() { + this(DEFAULT_MAX_STEPS, DEFAULT_MAX_CHARS, DEFAULT_MERGE_WINDOW_MS); + } + + public TextEditHistory(int maxSteps, int maxChars, long mergeWindowMs) { + this.maxSteps = Math.max(1, maxSteps); + this.maxChars = Math.max(1, maxChars); + this.mergeWindowMs = mergeWindowMs; + } + + public void setListener(@Nullable Listener listener) { + this.listener = listener; + lastCanUndo = canUndo(); + lastCanRedo = canRedo(); + if (listener != null) listener.onHistoryStateChanged(lastCanUndo, lastCanRedo); + } + + public boolean canUndo() { + return !undo.isEmpty(); + } + + public boolean canRedo() { + return !redo.isEmpty(); + } + + /** + * Records a change the user made to {@code field}: {@code removed} at {@code start} was + * replaced by {@code inserted}, with {@code [selectionStart, selectionEnd]} selected before it. + * A new change makes everything that could be redone unreachable, so redo is cleared. + */ + public void record( + int field, + int start, + @NonNull String removed, + @NonNull String inserted, + int selectionStart, + int selectionEnd, + long now) { + if (removed.equals(inserted)) return; + redo.clear(); + int caret = start + inserted.length(); + Step top = undo.peekLast(); + Step merged = top == null ? null : merge(top, field, start, removed, inserted, caret, now); + if (merged != null) { + undo.pollLast(); + totalChars -= top.size(); + // Typed and then backspaced away again: nothing is left to undo. + if (!merged.removed.isEmpty() || !merged.inserted.isEmpty()) push(merged); + } else { + if (top != null && !top.sealed) { + undo.pollLast(); + undo.addLast(top.sealed()); + } + push( + new Step( + field, + start, + removed, + inserted, + selectionStart, + selectionEnd, + caret, + caret, + now, + false)); + } + recountRedo(); + trim(); + notifyIfChanged(); + } + + /** Closes the step being typed, so the next change starts a new one. */ + public void seal() { + Step top = undo.peekLast(); + if (top != null && !top.sealed) { + undo.pollLast(); + undo.addLast(top.sealed()); + } + } + + /** Takes back the last step, or returns null when there is nothing to undo. */ + @Nullable + public Change undo() { + Step step = undo.pollLast(); + if (step == null) return null; + step = step.sealed(); + redo.addLast(step); + notifyIfChanged(); + return new Change( + step.field, + step.start, + step.inserted.length(), + step.removed, + step.selectionBeforeStart, + step.selectionBeforeEnd); + } + + /** Applies the last undone step again, or returns null when there is nothing to redo. */ + @Nullable + public Change redo() { + Step step = redo.pollLast(); + if (step == null) return null; + undo.addLast(step); + notifyIfChanged(); + return new Change( + step.field, + step.start, + step.removed.length(), + step.inserted, + step.selectionAfterStart, + step.selectionAfterEnd); + } + + /** Forgets everything; used when a different note, or another version of it, is shown. */ + public void clear() { + undo.clear(); + redo.clear(); + totalChars = 0; + notifyIfChanged(); + } + + /** + * The most recent steps whose text fits in {@code maxChars}, for the saved-instance state. + * Steps nearest the current text are kept: the newest undo steps and the next redo steps. + */ + @NonNull + public Snapshot snapshot(int maxChars, @NonNull String title, @NonNull String body) { + List undoKept = new ArrayList<>(); + List redoKept = new ArrayList<>(); + int budget = Math.max(0, maxChars); + Iterator undoIt = undo.descendingIterator(); + Iterator redoIt = redo.descendingIterator(); + boolean undoOpen = true; + boolean redoOpen = true; + // Alternate so neither direction is starved by the other. + while (undoOpen || redoOpen) { + if (undoOpen) { + if (undoIt.hasNext()) { + Step s = undoIt.next(); + if (s.size() <= budget) { + budget -= s.size(); + undoKept.add(0, s); + } else undoOpen = false; + } else undoOpen = false; + } + if (redoOpen) { + if (redoIt.hasNext()) { + Step s = redoIt.next(); + if (s.size() <= budget) { + budget -= s.size(); + redoKept.add(s); + } else redoOpen = false; + } else redoOpen = false; + } + } + // Redo steps are stored oldest first after the undo steps: the next redo comes last. + List all = new ArrayList<>(undoKept); + for (int i = redoKept.size() - 1; i >= 0; i--) all.add(redoKept.get(i)); + int n = all.size(); + int[] fields = new int[n]; + int[] starts = new int[n]; + String[] removed = new String[n]; + String[] inserted = new String[n]; + int[] selections = new int[n * 4]; + for (int i = 0; i < n; i++) { + Step s = all.get(i); + fields[i] = s.field; + starts[i] = s.start; + removed[i] = s.removed; + inserted[i] = s.inserted; + selections[i * 4] = s.selectionBeforeStart; + selections[i * 4 + 1] = s.selectionBeforeEnd; + selections[i * 4 + 2] = s.selectionAfterStart; + selections[i * 4 + 3] = s.selectionAfterEnd; + } + return new Snapshot( + fields, + starts, + removed, + inserted, + selections, + undoKept.size(), + title.hashCode(), + body.hashCode()); + } + + /** + * Replaces the history with a saved one, if it was saved for exactly these texts. + * + * @return whether it was restored; otherwise the history is left empty. + */ + public boolean restore( + @Nullable Snapshot snapshot, @NonNull String title, @NonNull String body) { + undo.clear(); + redo.clear(); + totalChars = 0; + boolean ok = + snapshot != null + && snapshot.isConsistent() + && snapshot.titleHash == title.hashCode() + && snapshot.bodyHash == body.hashCode(); + if (ok) { + int n = snapshot.fields.length; + for (int i = 0; i < n; i++) { + if (snapshot.removed[i] == null || snapshot.inserted[i] == null) { + undo.clear(); + redo.clear(); + ok = false; + break; + } + Step s = + new Step( + snapshot.fields[i], + snapshot.starts[i], + snapshot.removed[i], + snapshot.inserted[i], + snapshot.selections[i * 4], + snapshot.selections[i * 4 + 1], + snapshot.selections[i * 4 + 2], + snapshot.selections[i * 4 + 3], + 0L, + true); + if (i < snapshot.undoCount) undo.addLast(s); + else redo.addLast(s); + } + } + totalChars = 0; + for (Step s : undo) totalChars += s.size(); + recountRedo(); + notifyIfChanged(); + return ok; + } + + // -- internals ------------------------------------------------------------------------------ + + private void push(Step step) { + undo.addLast(step); + totalChars += step.size(); + } + + private void recountRedo() { + redoChars = 0; + for (Step s : redo) redoChars += s.size(); + } + + /** Drops the oldest steps until the history is within its limits; the newest always stays. */ + private void trim() { + while (undo.size() > 1 && (undo.size() > maxSteps || totalChars + redoChars > maxChars)) { + Step dropped = undo.pollFirst(); + if (dropped != null) totalChars -= dropped.size(); + } + } + + /** + * Joins a change to the open step before it when it continues it, or returns null. + * + *

    + *
  • Inside or at the end of what the step put in: typing on, a keyboard rewriting the word + * it is composing, a typo corrected with backspace. Appending after whitespace starts a + * new word and so a new step. + *
  • Backspace or forward delete right next to what the step removed. + *
+ */ + @Nullable + private Step merge( + Step top, int field, int start, String removed, String inserted, int caret, long now) { + if (top.sealed || top.field != field || now - top.time > mergeWindowMs || now < top.time) { + return null; + } + int topInsertedEnd = top.start + top.inserted.length(); + + // Within (or right at the end of) the text this step inserted. Typing after a deletion + // is a step of its own, so an undo does not take both back at once. + if (!top.inserted.isEmpty() + && start >= top.start + && start + removed.length() <= topInsertedEnd) { + boolean append = start == topInsertedEnd && removed.isEmpty(); + if (append && startsNewWord(top.inserted, inserted)) return null; + if (append && inserted.length() > 1) { + // A paste or a whole suggested word is a step of its own. + return null; + } + int from = start - top.start; + String combined = + top.inserted.substring(0, from) + + inserted + + top.inserted.substring(from + removed.length()); + return new Step( + field, + top.start, + top.removed, + combined, + top.selectionBeforeStart, + top.selectionBeforeEnd, + caret, + caret, + now, + false); + } + + // Deleting on past what this step already removed. + if (inserted.isEmpty() && top.inserted.isEmpty() && !removed.isEmpty()) { + if (start + removed.length() == top.start) { + // Backspace. + if (startsNewWordBackwards(removed, top.removed)) return null; + return new Step( + field, + start, + removed + top.removed, + "", + top.selectionBeforeStart, + top.selectionBeforeEnd, + caret, + caret, + now, + false); + } + if (start == top.start) { + // Forward delete. + return new Step( + field, + top.start, + top.removed + removed, + "", + top.selectionBeforeStart, + top.selectionBeforeEnd, + caret, + caret, + now, + false); + } + } + return null; + } + + /** Typing a letter after whitespace begins a new word. */ + private static boolean startsNewWord(String before, String next) { + if (before.isEmpty() || next.isEmpty()) return false; + return Character.isWhitespace(before.charAt(before.length() - 1)) + && !Character.isWhitespace(next.charAt(0)); + } + + /** Backspacing into whitespace after deleting a word ends that word's step. */ + private static boolean startsNewWordBackwards(String removedNow, String removedBefore) { + if (removedNow.isEmpty() || removedBefore.isEmpty()) return false; + return Character.isWhitespace(removedNow.charAt(removedNow.length() - 1)) + && !Character.isWhitespace(removedBefore.charAt(0)); + } + + private void notifyIfChanged() { + boolean u = canUndo(); + boolean r = canRedo(); + if (u == lastCanUndo && r == lastCanRedo) return; + lastCanUndo = u; + lastCanRedo = r; + if (listener != null) listener.onHistoryStateChanged(u, r); + } +} diff --git a/app/src/main/java/com/pasich/mynotes/utils/encly/AndroidEnclyInspector.java b/app/src/main/java/com/pasich/mynotes/utils/encly/AndroidEnclyInspector.java new file mode 100644 index 00000000..dd091220 --- /dev/null +++ b/app/src/main/java/com/pasich/mynotes/utils/encly/AndroidEnclyInspector.java @@ -0,0 +1,105 @@ +package com.pasich.mynotes.utils.encly; + +import android.annotation.SuppressLint; +import android.content.Context; +import android.content.Intent; +import android.content.pm.ActivityInfo; +import android.content.pm.PackageInfo; +import android.content.pm.PackageManager; +import android.content.pm.ResolveInfo; +import android.content.pm.Signature; +import android.net.Uri; +import android.os.Build; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import java.util.ArrayList; +import java.util.List; + +/** {@link EnclyReceiverCheck.Inspector} backed by the real package manager. */ +public final class AndroidEnclyInspector implements EnclyReceiverCheck.Inspector { + + private final PackageManager packageManager; + private final Uri probeUri; + + /** + * @param probeUri a URI of the same shape as the real hand-off file, so the lookup matches + * Encly's intent filter exactly as the launch will. + */ + public AndroidEnclyInspector(@NonNull Context context, @NonNull Uri probeUri) { + this.packageManager = context.getPackageManager(); + this.probeUri = probeUri; + } + + @Override + public int sdkInt() { + return Build.VERSION.SDK_INT; + } + + @Override + @SuppressWarnings("deprecation") + public boolean isInstalled(@NonNull String packageName) { + try { + packageManager.getPackageInfo(packageName, 0); + return true; + } catch (PackageManager.NameNotFoundException e) { + return false; + } + } + + @Nullable + @Override + public String findActivity(@NonNull String packageName, @NonNull String action) { + Intent withData = new Intent(action).setPackage(packageName); + withData.setDataAndType(probeUri, EnclyHandoff.MIME_TYPE); + String found = firstExported(withData, packageName); + if (found != null) return found; + // A filter that declares only the action does not match an intent carrying a type; the + // launch names the component explicitly, so the action alone is enough to reach it. + return firstExported(new Intent(action).setPackage(packageName), packageName); + } + + @Nullable + @SuppressWarnings("deprecation") + private String firstExported(@NonNull Intent intent, @NonNull String packageName) { + List matches = packageManager.queryIntentActivities(intent, 0); + if (matches == null) return null; + for (ResolveInfo match : matches) { + ActivityInfo info = match.activityInfo; + if (info != null && info.exported && packageName.equals(info.packageName)) { + return info.name; + } + } + return null; + } + + @Override + public boolean hasSigningCertificateSha256( + @NonNull String packageName, @NonNull byte[] sha256) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) return false; + return packageManager.hasSigningCertificate( + packageName, sha256, PackageManager.CERT_INPUT_SHA256); + } + + /** + * Only reached below API 28. {@code GET_SIGNATURES} is unsafe when it is trusted with several + * signers; {@link EnclyReceiverCheck} accepts exactly one. + */ + @Nullable + @Override + @SuppressLint("PackageManagerGetSignatures") + @SuppressWarnings("deprecation") + public List legacySignatures(@NonNull String packageName) { + try { + PackageInfo info = + packageManager.getPackageInfo(packageName, PackageManager.GET_SIGNATURES); + if (info.signatures == null) return null; + List out = new ArrayList<>(info.signatures.length); + for (Signature signature : info.signatures) { + out.add(signature == null ? null : signature.toByteArray()); + } + return out; + } catch (PackageManager.NameNotFoundException e) { + return null; + } + } +} diff --git a/app/src/main/java/com/pasich/mynotes/utils/encly/EnclyHandoff.java b/app/src/main/java/com/pasich/mynotes/utils/encly/EnclyHandoff.java new file mode 100644 index 00000000..c1455347 --- /dev/null +++ b/app/src/main/java/com/pasich/mynotes/utils/encly/EnclyHandoff.java @@ -0,0 +1,43 @@ +package com.pasich.mynotes.utils.encly; + +/** + * The hand-off contract shared with Encly (pasichDev/MyNotes#167, pasichDev/Encly#46). + * + *

Every value here is read by the other app as well, so none of them may change without a new + * {@link #SCHEMA} on both sides. + */ +public final class EnclyHandoff { + + public static final String ENCLY_PACKAGE = "com.pasich.encly"; + public static final String ACTION_IMPORT = "com.pasich.encly.action.IMPORT_FROM_MY_NOTES"; + + /** + * SHA-256 of Encly's signing certificate. The same certificate signs the Google Play, F-Droid + * and GitHub builds, so one pin covers every legitimate install. + */ + public static final String ENCLY_CERT_SHA256 = + "6884c693354964276231e6d0336b965f690bf3f15a0707bb59505c208e5e7554"; + + public static final String MIME_TYPE = "application/zip"; + public static final String ENTRY_NAME = "handoff.json"; + public static final String FORMAT = "mynotes-handoff"; + public static final int SCHEMA = 1; + + /** Subdirectory of {@code cacheDir}, exposed by the FileProvider as {@code encly_handoff}. */ + public static final String CACHE_DIR = "encly-handoff"; + + // Result extras (RESULT_OK) + public static final String EXTRA_NOTES = "notes"; + public static final String EXTRA_TASKS = "tasks"; + public static final String EXTRA_TAGS = "tags"; + public static final String EXTRA_SKIPPED = "skipped"; + + // Result extra (RESULT_CANCELED) + public static final String EXTRA_REASON = "reason"; + + public static final String MARKET_URI = "market://details?id=" + ENCLY_PACKAGE; + public static final String WEB_STORE_URI = + "https://play.google.com/store/apps/details?id=" + ENCLY_PACKAGE; + + private EnclyHandoff() {} +} diff --git a/app/src/main/java/com/pasich/mynotes/utils/encly/EnclyMigrationRepository.java b/app/src/main/java/com/pasich/mynotes/utils/encly/EnclyMigrationRepository.java new file mode 100644 index 00000000..79852a28 --- /dev/null +++ b/app/src/main/java/com/pasich/mynotes/utils/encly/EnclyMigrationRepository.java @@ -0,0 +1,135 @@ +package com.pasich.mynotes.utils.encly; + +import android.content.Context; +import androidx.annotation.NonNull; +import androidx.annotation.WorkerThread; +import com.pasich.mynotes.data.database.AppDatabase; +import com.pasich.mynotes.data.database.entities.SyncMetadataEntity; +import com.pasich.mynotes.data.model.Note; +import com.pasich.mynotes.data.model.Tag; +import com.pasich.mynotes.data.model.Task; +import com.pasich.mynotes.data.model.TaskCategory; +import com.pasich.mynotes.data.sync.SyncMutationCoordinator; +import com.pasich.mynotes.extendedEditor.attach.AttachmentStorage; +import com.pasich.mynotes.utils.editor.NoteViewStateStore; +import com.pasich.mynotes.utils.reminder.ReminderManager; +import com.pasich.mynotes.utils.reminder.TaskReminderManager; +import dagger.hilt.android.qualifiers.ApplicationContext; +import java.io.File; +import java.io.IOException; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import javax.inject.Inject; +import javax.inject.Singleton; + +/** Reads the library for the Encly hand-off, and clears it afterwards when the user asks. */ +@Singleton +public class EnclyMigrationRepository { + + private final Context context; + private final AppDatabase database; + private final SyncMutationCoordinator syncMutationCoordinator; + + @Inject + public EnclyMigrationRepository( + @ApplicationContext Context context, + AppDatabase database, + SyncMutationCoordinator syncMutationCoordinator) { + this.context = context; + this.database = database; + this.syncMutationCoordinator = syncMutationCoordinator; + } + + /** Everything the hand-off would carry, read in one consistent snapshot. */ + public static final class Library { + public final List notes; + public final List tags; + public final List tasks; + public final List categories; + + Library(List notes, List tags, List tasks, List categories) { + this.notes = notes; + this.tags = tags; + this.tasks = tasks; + this.categories = categories; + } + + @NonNull + public HandoffPayloadBuilder.Summary summary() { + return HandoffPayloadBuilder.Summary.of(notes, tags, tasks); + } + } + + @WorkerThread + @NonNull + public Library load() { + return database.runInTransaction( + () -> + new Library( + database.noteDao().getAllNotesSync(), + database.tagsDao().getUserTagsSync(), + database.taskDao().getAllTasksSync(), + database.taskCategoryDao().getCategoriesSync())); + } + + /** Writes the hand-off ZIP into the cache and returns it. */ + @WorkerThread + @NonNull + public File writeArchive(@NonNull Library library) throws IOException { + Map stableIds = new HashMap<>(); + for (SyncMetadataEntity row : database.syncMetadataDao().getAll()) { + stableIds.put(key(row.recordType, row.localId), row.stableId); + } + HandoffPayloadBuilder payload = + new HandoffPayloadBuilder( + library.notes, + library.tags, + library.tasks, + library.categories, + (recordType, localId) -> stableIds.get(key(recordType, localId)), + System.currentTimeMillis()); + return HandoffArchive.write(context.getCacheDir(), payload); + } + + /** Deletes every hand-off file left in the cache. */ + public void clearArchives() { + HandoffArchive.clear(context.getCacheDir()); + } + + /** + * Deletes notes, tasks, tags, task categories and attachment files. Only ever called after + * Encly confirmed the move and the user confirmed this separate step. + */ + @WorkerThread + public void clearAllData() { + Library library = load(); + for (Note note : library.notes) { + if (note.getReminderTime() != null) { + ReminderManager.cancelReminder(context, note.getId()); + } + } + for (Task task : library.tasks) { + if (task.getReminderTime() != null) { + TaskReminderManager.cancelReminder(context, task.getId()); + } + } + syncMutationCoordinator.clearAllUserData(); + new NoteViewStateStore(context).clear(); + deleteRecursively(AttachmentStorage.baseDirPath(context)); + } + + private static String key(String recordType, long localId) { + return recordType + ":" + localId; + } + + private static void deleteRecursively(File file) { + if (file == null || !file.exists()) return; + File[] children = file.listFiles(); + if (children != null) { + for (File child : children) deleteRecursively(child); + } + //noinspection ResultOfMethodCallIgnored + file.delete(); + } +} diff --git a/app/src/main/java/com/pasich/mynotes/utils/encly/EnclyReceiverCheck.java b/app/src/main/java/com/pasich/mynotes/utils/encly/EnclyReceiverCheck.java new file mode 100644 index 00000000..29fe3f52 --- /dev/null +++ b/app/src/main/java/com/pasich/mynotes/utils/encly/EnclyReceiverCheck.java @@ -0,0 +1,125 @@ +package com.pasich.mynotes.utils.encly; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.List; + +/** + * Decides whether the installed Encly may be handed every note in plain text. + * + *

The package name alone proves nothing — anything can be installed as {@code com.pasich.encly} + * from outside a store — so the signing certificate is pinned. The package manager calls sit behind + * {@link Inspector}, which keeps the decision itself, including the pre-API 28 path, under JVM + * tests. + */ +public final class EnclyReceiverCheck { + + /** First API level with {@code PackageManager.hasSigningCertificate}. */ + static final int API_HAS_SIGNING_CERTIFICATE = 28; + + public enum Status { + /** Encly is not installed: offer to get it. */ + NOT_INSTALLED, + /** Encly is installed but too old to receive a hand-off: ask for an update. */ + NEEDS_UPDATE, + /** Something named Encly is installed but not signed by Encly's key. Never send to it. */ + UNTRUSTED, + /** The official Encly is installed and can receive. */ + READY + } + + /** The package manager, reduced to what the decision needs. */ + public interface Inspector { + int sdkInt(); + + boolean isInstalled(@NonNull String packageName); + + /** + * The fully qualified class name of the exported activity in {@code packageName} that + * handles {@code action}, or {@code null} when none does. + */ + @Nullable + String findActivity(@NonNull String packageName, @NonNull String action); + + /** API 28+: {@code PackageManager.hasSigningCertificate(..., CERT_INPUT_SHA256)}. */ + boolean hasSigningCertificateSha256(@NonNull String packageName, @NonNull byte[] sha256); + + /** + * API 26–27: the encoded certificates from {@code GET_SIGNATURES}, or {@code null} when the + * package cannot be read. + */ + @Nullable + List legacySignatures(@NonNull String packageName); + } + + public static final class Result { + @NonNull public final Status status; + + /** The activity to start explicitly; set only when {@link #status} is READY. */ + @Nullable public final String activityClassName; + + Result(@NonNull Status status, @Nullable String activityClassName) { + this.status = status; + this.activityClassName = activityClassName; + } + } + + private EnclyReceiverCheck() {} + + @NonNull + public static Result check(@NonNull Inspector inspector) { + return check(inspector, EnclyHandoff.ENCLY_CERT_SHA256); + } + + /** {@link #check(Inspector)} against any pin, so tests can sign with a certificate they own. */ + @NonNull + static Result check(@NonNull Inspector inspector, @NonNull String pinnedSha256Hex) { + String pkg = EnclyHandoff.ENCLY_PACKAGE; + if (!inspector.isInstalled(pkg)) { + return new Result(Status.NOT_INSTALLED, null); + } + if (!isSignedBy(inspector, pkg, hexToBytes(pinnedSha256Hex))) { + return new Result(Status.UNTRUSTED, null); + } + String activity = inspector.findActivity(pkg, EnclyHandoff.ACTION_IMPORT); + if (activity == null || activity.isEmpty()) { + return new Result(Status.NEEDS_UPDATE, null); + } + return new Result(Status.READY, activity); + } + + static boolean isSignedBy( + @NonNull Inspector inspector, @NonNull String pkg, @NonNull byte[] pinned) { + if (inspector.sdkInt() >= API_HAS_SIGNING_CERTIFICATE) { + return inspector.hasSigningCertificateSha256(pkg, pinned); + } + // GET_SIGNATURES on API < 28 returns every signer, and is only safe to trust when there is + // exactly one: with several, an attacker's certificate could sit beside the real one. + List signatures = inspector.legacySignatures(pkg); + if (signatures == null || signatures.size() != 1 || signatures.get(0) == null) { + return false; + } + return MessageDigest.isEqual(pinned, sha256(signatures.get(0))); + } + + @NonNull + static byte[] sha256(@NonNull byte[] data) { + try { + return MessageDigest.getInstance("SHA-256").digest(data); + } catch (NoSuchAlgorithmException impossible) { + throw new IllegalStateException(impossible); + } + } + + @NonNull + static byte[] hexToBytes(@NonNull String hex) { + if (hex.length() % 2 != 0) throw new IllegalArgumentException("odd hex length"); + byte[] out = new byte[hex.length() / 2]; + for (int i = 0; i < out.length; i++) { + out[i] = (byte) Integer.parseInt(hex.substring(i * 2, i * 2 + 2), 16); + } + return out; + } +} diff --git a/app/src/main/java/com/pasich/mynotes/utils/encly/HandoffArchive.java b/app/src/main/java/com/pasich/mynotes/utils/encly/HandoffArchive.java new file mode 100644 index 00000000..7c31c02c --- /dev/null +++ b/app/src/main/java/com/pasich/mynotes/utils/encly/HandoffArchive.java @@ -0,0 +1,69 @@ +package com.pasich.mynotes.utils.encly; + +import androidx.annotation.NonNull; +import java.io.BufferedOutputStream; +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.OutputStreamWriter; +import java.io.Writer; +import java.nio.charset.StandardCharsets; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; + +/** + * The hand-off file: a ZIP in {@code cacheDir/encly-handoff} holding exactly {@code handoff.json}. + * + *

It is every note in plain text, so it lives only as long as the hand-off: it is deleted when + * Encly answers, whatever the answer, and anything left over from a crash or a killed process is + * swept on the next start. + */ +public final class HandoffArchive { + + private HandoffArchive() {} + + @NonNull + public static File directory(@NonNull File cacheDir) { + return new File(cacheDir, EnclyHandoff.CACHE_DIR); + } + + /** Writes a fresh archive and returns it; a partial file is removed if writing fails. */ + @NonNull + public static File write(@NonNull File cacheDir, @NonNull HandoffPayloadBuilder payload) + throws IOException { + File dir = directory(cacheDir); + if (!dir.isDirectory() && !dir.mkdirs()) { + throw new IOException("Cannot create " + dir); + } + File file = new File(dir, "handoff-" + System.currentTimeMillis() + ".zip"); + boolean done = false; + try (ZipOutputStream zip = + new ZipOutputStream(new BufferedOutputStream(new FileOutputStream(file)))) { + zip.putNextEntry(new ZipEntry(EnclyHandoff.ENTRY_NAME)); + Writer writer = new OutputStreamWriter(zip, StandardCharsets.UTF_8); + payload.write(writer); + writer.flush(); + zip.closeEntry(); + done = true; + } finally { + if (!done) { + //noinspection ResultOfMethodCallIgnored + file.delete(); + } + } + return file; + } + + /** Deletes every hand-off file. Returns the number of files that could not be deleted. */ + public static int clear(@NonNull File cacheDir) { + File dir = directory(cacheDir); + File[] files = dir.listFiles(); + int failed = 0; + if (files != null) { + for (File file : files) { + if (!file.delete() && file.exists()) failed++; + } + } + return failed; + } +} diff --git a/app/src/main/java/com/pasich/mynotes/utils/encly/HandoffPayloadBuilder.java b/app/src/main/java/com/pasich/mynotes/utils/encly/HandoffPayloadBuilder.java new file mode 100644 index 00000000..2ea7c061 --- /dev/null +++ b/app/src/main/java/com/pasich/mynotes/utils/encly/HandoffPayloadBuilder.java @@ -0,0 +1,246 @@ +package com.pasich.mynotes.utils.encly; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import com.google.gson.JsonElement; +import com.google.gson.JsonParser; +import com.google.gson.stream.JsonWriter; +import com.pasich.mynotes.data.model.Note; +import com.pasich.mynotes.data.model.Tag; +import com.pasich.mynotes.data.model.Task; +import com.pasich.mynotes.data.model.TaskCategory; +import com.pasich.mynotes.data.sync.SyncMetadata; +import com.pasich.mynotes.extendedEditor.attach.EditorAttachmentBlocks; +import com.pasich.mynotes.utils.managers.SystemTagsManager; +import java.io.IOException; +import java.io.StringWriter; +import java.io.Writer; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +/** + * Writes {@code handoff.json} — the raw My Notes data Encly imports. + * + *

Raw on purpose: Encly maps it to its own blocks, so the Encly format stays private to Encly. + * Attachment files and reminders are never sent; each note only says how many attachments stay + * behind. Free of {@code android.*} so the exact wire shape is pinned by JVM tests. + */ +public final class HandoffPayloadBuilder { + + /** Looks up the durable sync identity of a local record. */ + public interface StableIds { + /** + * @return {@code sync_metadata.stableId} for that record, or {@code null} when it has none. + */ + @Nullable + String find(@NonNull String recordType, long localId); + } + + private final List notes; + private final List tags; + private final List tasks; + private final List categories; + private final StableIds stableIds; + private final long exportedAt; + + public HandoffPayloadBuilder( + @NonNull List notes, + @NonNull List tags, + @NonNull List tasks, + @NonNull List categories, + @NonNull StableIds stableIds, + long exportedAt) { + this.notes = notes; + this.tags = tags; + this.tasks = tasks; + this.categories = categories; + this.stableIds = stableIds; + this.exportedAt = exportedAt; + } + + /** The stable id used on the wire: the sync identity, else {@code mynotes::}. */ + @NonNull + public static String stableId( + @NonNull StableIds stableIds, @NonNull String recordType, long localId) { + String stable = stableIds.find(recordType, localId); + if (stable != null && !stable.trim().isEmpty()) return stable; + return "mynotes:" + recordType + ":" + localId; + } + + /** + * Attachments that stay in My Notes: the larger of the note's attachment list and the file + * references in its Editor.js document, so a note whose list is stale still says what is lost. + */ + public static int attachmentCount(@NonNull Note note) { + int listed = 0; + String json = note.getAttachments(); + if (json != null && !json.trim().isEmpty()) { + try { + JsonElement parsed = JsonParser.parseString(json); + if (parsed.isJsonArray()) listed = parsed.getAsJsonArray().size(); + } catch (RuntimeException unreadable) { + listed = 0; + } + } + int referenced = EditorAttachmentBlocks.fileUrls(note.getValueJson()).size(); + return Math.max(listed, referenced); + } + + /** User tags only; system tags are UI rows, not data. */ + public static boolean isUserTag(@NonNull Tag tag) { + return tag.getSystemAction() == SystemTagsManager.SYSTEM_ACTION_USER_TAG; + } + + /** + * The text of a task. The contract carries one {@code description}; a My Notes task has a + * required title and an optional description, so both travel, title first. + */ + @NonNull + public static String taskText(@NonNull Task task) { + String title = task.getTitle() == null ? "" : task.getTitle(); + String description = task.getDescription(); + if (description == null || description.trim().isEmpty()) return title; + if (title.isEmpty()) return description; + return title + "\n" + description; + } + + @NonNull + public String toJson() { + StringWriter out = new StringWriter(); + try { + write(out); + } catch (IOException impossible) { + throw new IllegalStateException(impossible); + } + return out.toString(); + } + + /** Streams the document, so a large library is never held twice in memory. */ + public void write(@NonNull Writer out) throws IOException { + JsonWriter json = new JsonWriter(out); + json.setHtmlSafe(false); + json.setSerializeNulls(true); + json.beginObject(); + json.name("format").value(EnclyHandoff.FORMAT); + json.name("schema").value(EnclyHandoff.SCHEMA); + json.name("exportedAt").value(exportedAt); + + json.name("tags").beginArray(); + for (Tag tag : tags) { + if (!isUserTag(tag)) continue; + json.beginObject(); + json.name("id").value(stableId(stableIds, SyncMetadata.RECORD_TYPE_TAG, tag.getId())); + json.name("name").value(tag.getNameTag()); + json.name("position").value(tag.getPosition()); + json.endObject(); + } + json.endArray(); + + json.name("notes").beginArray(); + for (Note note : notes) { + json.beginObject(); + json.name("id").value(stableId(stableIds, SyncMetadata.RECORD_TYPE_NOTE, note.getId())); + json.name("title").value(note.getTitle()); + json.name("value").value(note.getValue()); + String valueJson = note.getValueJson(); + if (valueJson.trim().isEmpty()) { + json.name("valueJson").nullValue(); + } else { + json.name("valueJson").value(valueJson); + } + json.name("date").value(note.getDate()); + String tag = note.getTag(); + if (tag.trim().isEmpty()) { + json.name("tag").nullValue(); + } else { + json.name("tag").value(tag); + } + json.name("isTrash").value(note.isTrash()); + json.name("isPinned").value(note.isPinned()); + json.name("attachments").value(attachmentCount(note)); + json.endObject(); + } + json.endArray(); + + Set categoryIds = new HashSet<>(); + json.name("taskCategories").beginArray(); + for (TaskCategory category : categories) { + categoryIds.add(category.getId()); + json.beginObject(); + json.name("id") + .value( + stableId( + stableIds, + SyncMetadata.RECORD_TYPE_CATEGORY, + category.getId())); + json.name("name").value(category.getName()); + json.name("position").value(category.getPosition()); + json.endObject(); + } + json.endArray(); + + json.name("tasks").beginArray(); + for (Task task : tasks) { + json.beginObject(); + json.name("id").value(stableId(stableIds, SyncMetadata.RECORD_TYPE_TASK, task.getId())); + json.name("description").value(taskText(task)); + json.name("isDone").value(task.isDone()); + json.name("createdAt").value(task.getCreatedAt()); + // 0 means "no category"; an id with no row would point Encly at nothing. + if (task.getCategoryId() != 0 && categoryIds.contains(task.getCategoryId())) { + json.name("categoryId") + .value( + stableId( + stableIds, + SyncMetadata.RECORD_TYPE_CATEGORY, + task.getCategoryId())); + } else { + json.name("categoryId").nullValue(); + } + json.name("position").value(task.getPosition()); + json.endObject(); + } + json.endArray(); + + json.endObject(); + json.flush(); + } + + /** Totals shown on the page before anything is sent. */ + public static final class Summary { + public final int notes; + public final int tasks; + public final int tags; + public final int attachments; + public final int reminders; + public final int pinned; + + public Summary(int notes, int tasks, int tags, int attachments, int reminders, int pinned) { + this.notes = notes; + this.tasks = tasks; + this.tags = tags; + this.attachments = attachments; + this.reminders = reminders; + this.pinned = pinned; + } + + @NonNull + public static Summary of( + @NonNull List notes, @NonNull List tags, @NonNull List tasks) { + int userTags = 0; + for (Tag tag : tags) if (isUserTag(tag)) userTags++; + int attachments = 0; + int reminders = 0; + int pinned = 0; + for (Note note : notes) { + attachments += attachmentCount(note); + if (note.getReminderTime() != null) reminders++; + if (note.isPinned()) pinned++; + } + for (Task task : tasks) if (task.getReminderTime() != null) reminders++; + return new Summary( + notes.size(), tasks.size(), userTags, attachments, reminders, pinned); + } + } +} diff --git a/app/src/main/java/com/pasich/mynotes/utils/encly/HandoffResult.java b/app/src/main/java/com/pasich/mynotes/utils/encly/HandoffResult.java new file mode 100644 index 00000000..2a24b1c5 --- /dev/null +++ b/app/src/main/java/com/pasich/mynotes/utils/encly/HandoffResult.java @@ -0,0 +1,86 @@ +package com.pasich.mynotes.utils.encly; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +/** + * What Encly answered. A missing reason — Encly crashed, or the user pressed back before it could + * answer — is read as a cancellation, never as a success. + */ +public final class HandoffResult { + + /** {@code android.app.Activity.RESULT_OK}; kept here so parsing stays a JVM concern. */ + public static final int RESULT_OK = -1; + + public enum Reason { + UNTRUSTED_CALLER("untrusted_caller"), + CANCELLED("cancelled"), + UNSUPPORTED_SCHEMA("unsupported_schema"), + INVALID_PAYLOAD("invalid_payload"), + TOO_LARGE("too_large"), + FAILED("failed"); + + public final String wire; + + Reason(String wire) { + this.wire = wire; + } + + @NonNull + static Reason fromWire(@Nullable String value) { + if (value == null || value.isEmpty()) return CANCELLED; + for (Reason reason : values()) { + if (reason.wire.equals(value)) return reason; + } + // A reason this build does not know yet is still a failure, not a silent cancel. + return FAILED; + } + } + + public final boolean success; + public final int notes; + public final int tasks; + public final int tags; + public final int skipped; + + /** Set only when {@link #success} is false. */ + @Nullable public final Reason reason; + + private HandoffResult( + boolean success, int notes, int tasks, int tags, int skipped, @Nullable Reason reason) { + this.success = success; + this.notes = notes; + this.tasks = tasks; + this.tags = tags; + this.skipped = skipped; + this.reason = reason; + } + + /** + * @param resultCode the activity result code. + * @param notes the {@code notes} extra, or {@code null} when absent; likewise for the rest. + */ + @NonNull + public static HandoffResult parse( + int resultCode, + @Nullable Integer notes, + @Nullable Integer tasks, + @Nullable Integer tags, + @Nullable Integer skipped, + @Nullable String reason) { + if (resultCode == RESULT_OK) { + return new HandoffResult( + true, + nonNegative(notes), + nonNegative(tasks), + nonNegative(tags), + nonNegative(skipped), + null); + } + return new HandoffResult(false, 0, 0, 0, 0, Reason.fromWire(reason)); + } + + private static int nonNegative(@Nullable Integer value) { + return value == null || value < 0 ? 0 : value; + } +} diff --git a/app/src/main/java/com/pasich/mynotes/utils/linkMovement/CustomLinkMovementMethod.java b/app/src/main/java/com/pasich/mynotes/utils/linkMovement/CustomLinkMovementMethod.java deleted file mode 100644 index 0d0659c3..00000000 --- a/app/src/main/java/com/pasich/mynotes/utils/linkMovement/CustomLinkMovementMethod.java +++ /dev/null @@ -1,54 +0,0 @@ -package com.pasich.mynotes.utils.linkMovement; - -import android.text.Layout; -import android.text.Selection; -import android.text.Spannable; -import android.text.method.LinkMovementMethod; -import android.text.style.URLSpan; -import android.view.MotionEvent; -import android.widget.TextView; - -public class CustomLinkMovementMethod extends LinkMovementMethod { - - private static CustomLinkMovementMethod INSTANCE; - - public static CustomLinkMovementMethod getInstance() { - if (INSTANCE == null) { - INSTANCE = new CustomLinkMovementMethod(); - } - return INSTANCE; - } - - @Override - public boolean onTouchEvent(TextView widget, Spannable buffer, MotionEvent event) { - int action = event.getAction(); - - if (action != MotionEvent.ACTION_UP && action != MotionEvent.ACTION_DOWN) - return super.onTouchEvent(widget, buffer, event); - - int x = (int) event.getX() - widget.getTotalPaddingLeft() + widget.getScrollX(); - int y = (int) event.getY() - widget.getTotalPaddingTop() + widget.getScrollY(); - - Layout layout = widget.getLayout(); - if (layout == null) return false; - - int line = layout.getLineForVertical(y); - int offset = layout.getOffsetForHorizontal(line, x); - - URLSpan[] links = buffer.getSpans(offset, offset, URLSpan.class); - if (links.length == 0) { - return false; - } - - URLSpan link = links[0]; - - if (action == MotionEvent.ACTION_DOWN) { - Selection.setSelection(buffer, buffer.getSpanStart(link), buffer.getSpanEnd(link)); - return true; - } - - Selection.removeSelection(buffer); - - return true; - } -} diff --git a/app/src/main/java/com/pasich/mynotes/utils/navigation/NoteExtras.java b/app/src/main/java/com/pasich/mynotes/utils/navigation/NoteExtras.java index 6323b118..18d0375c 100644 --- a/app/src/main/java/com/pasich/mynotes/utils/navigation/NoteExtras.java +++ b/app/src/main/java/com/pasich/mynotes/utils/navigation/NoteExtras.java @@ -7,4 +7,7 @@ private NoteExtras() {} // no instances public static final String EXTRA_NEW_NOTE = "NewNote"; public static final String EXTRA_ID_NOTE = "idNote"; public static final String EXTRA_TAG_NOTE = "tagNote"; + + /** Whether the note has attachments, so a reminder opens it in the extended editor. */ + public static final String EXTRA_HAS_ATTACHMENTS = "hasAttachments"; } diff --git a/app/src/main/java/com/pasich/mynotes/utils/navigation/NoteNavigator.java b/app/src/main/java/com/pasich/mynotes/utils/navigation/NoteNavigator.java index d683d1cb..23cd459e 100644 --- a/app/src/main/java/com/pasich/mynotes/utils/navigation/NoteNavigator.java +++ b/app/src/main/java/com/pasich/mynotes/utils/navigation/NoteNavigator.java @@ -1,6 +1,7 @@ package com.pasich.mynotes.utils.navigation; import android.app.Activity; +import android.content.Context; import android.content.Intent; import android.view.View; import androidx.annotation.NonNull; @@ -13,6 +14,35 @@ /** Navigates to the appropriate note editor based on settings and note type. */ public record NoteNavigator(Activity activity, ThemePreferencesCache prefs) { + /** + * The editor a note opens in: the extended one when it is switched on, and always for a note + * with attachments, which the simple editor cannot show. + */ + @NonNull + public static Class editorFor( + boolean extendedEditorEnabled, boolean hasAttachments) { + return extendedEditorEnabled || hasAttachments + ? NoteExtendedEditorActivity.class + : NoteActivity.class; + } + + /** + * An intent that opens an existing note in the right editor, for callers without an activity to + * start it from (a notification, a trampoline). + */ + @NonNull + public static Intent existingNoteIntent( + @NonNull Context context, + @NonNull ThemePreferencesCache prefs, + long noteId, + boolean hasAttachments) { + Intent intent = + new Intent(context, editorFor(prefs.isExtendedEditorEnabled(), hasAttachments)); + intent.putExtra(NoteExtras.EXTRA_NEW_NOTE, false); + intent.putExtra(NoteExtras.EXTRA_ID_NOTE, noteId); + return intent; + } + /** Opens a note by model, optionally with a shared-element transition. */ public void openNote( @NonNull Note note, @@ -33,11 +63,7 @@ public void openNote( boolean isAttachesNote) { Intent intent = - new Intent( - activity, - prefs.isExtendedEditorEnabled() || isAttachesNote - ? NoteExtendedEditorActivity.class - : NoteActivity.class); + new Intent(activity, editorFor(prefs.isExtendedEditorEnabled(), isAttachesNote)); intent.putExtra(NoteExtras.EXTRA_NEW_NOTE, isNew); intent.putExtra(NoteExtras.EXTRA_ID_NOTE, noteId); diff --git a/app/src/main/java/com/pasich/mynotes/utils/recycler/DragTarget.java b/app/src/main/java/com/pasich/mynotes/utils/recycler/DragTarget.java new file mode 100644 index 00000000..d778e9f9 --- /dev/null +++ b/app/src/main/java/com/pasich/mynotes/utils/recycler/DragTarget.java @@ -0,0 +1,67 @@ +package com.pasich.mynotes.utils.recycler; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import java.util.List; + +/** + * Picks the card a dragged card trades places with, kept free of Android types so it is covered by + * plain JVM tests. + * + *

The card under the middle of the dragged card is the one, once that middle has passed the + * middle of the card toward it. {@code ItemTouchHelper}'s own choice wants the dragged card's edge + * past the other card's edge: onto the first card of the list that means above the top of the list, + * so a drop there opened the card's menu instead. Waiting for the middle keeps two cards of + * different heights from trading places back and forth. + */ +public final class DragTarget { + + private DragTarget() {} + + /** A card's position in the list and its bounds on screen. */ + public record Card(int position, int left, int top, int right, int bottom) { + int centerX() { + return (left + right) / 2; + } + + int centerY() { + return (top + bottom) / 2; + } + + boolean contains(int x, int y) { + return x >= left && x < right && y >= top && y < bottom; + } + } + + /** + * @param slot where the dragged card is laid out (not where it is drawn under the finger). + * @param centerX the middle of the dragged card as drawn now. + * @param centerY the middle of the dragged card as drawn now. + * @param candidates cards it may trade places with. + * @return the card to trade places with, or null to stay. + */ + @Nullable + public static Card choose( + @NonNull Card slot, int centerX, int centerY, @NonNull List candidates) { + Card best = null; + long bestDistance = Long.MAX_VALUE; + for (Card card : candidates) { + if (card.position() == slot.position() || !card.contains(centerX, centerY)) continue; + int dx = card.centerX() - slot.centerX(); + int dy = card.centerY() - slot.centerY(); + boolean passed = + Math.abs(dx) > Math.abs(dy) + ? Integer.signum(centerX - card.centerX()) * Integer.signum(dx) >= 0 + : Integer.signum(centerY - card.centerY()) * Integer.signum(dy) >= 0; + if (!passed) continue; + long ddx = centerX - card.centerX(); + long ddy = centerY - card.centerY(); + long distance = ddx * ddx + ddy * ddy; + if (distance < bestDistance) { + bestDistance = distance; + best = card; + } + } + return best; + } +} diff --git a/app/src/main/java/com/pasich/mynotes/utils/recycler/NoteDragCallback.java b/app/src/main/java/com/pasich/mynotes/utils/recycler/NoteDragCallback.java new file mode 100644 index 00000000..f69257e8 --- /dev/null +++ b/app/src/main/java/com/pasich/mynotes/utils/recycler/NoteDragCallback.java @@ -0,0 +1,333 @@ +package com.pasich.mynotes.utils.recycler; + +import android.animation.ValueAnimator; +import android.graphics.Canvas; +import android.graphics.Rect; +import android.view.View; +import android.view.animation.DecelerateInterpolator; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.core.view.ViewCompat; +import androidx.recyclerview.widget.ItemTouchHelper; +import androidx.recyclerview.widget.ItemTouchHelperAccess; +import androidx.recyclerview.widget.RecyclerView; +import java.util.ArrayList; +import java.util.List; + +/** + * Swipes on the notes list and, in the custom order, drags of the cards in the list and the grid. + * The screen decides what is allowed and what a move or a drop means ({@link Host}); this class + * handles how a dragged card finds its place and stays on screen. + */ +public class NoteDragCallback extends SwipeToListNotesCallback { + + /** The screen the cards belong to. */ + public interface Host { + /** Whether cards can be dragged now (custom order, no selection, no search). */ + boolean canDrag(); + + /** Whether cards can be swiped now. */ + boolean canSwipe(); + + /** Whether the cards at the two positions may trade places (same section). */ + boolean canTrade(int first, int second); + + /** Whether a drag is in progress, from pick-up until its drop is behind the list. */ + boolean isDragging(); + + /** Moves the dragged card in the list on screen. */ + void move(int from, int to); + + void onDragStarted(@NonNull RecyclerView.ViewHolder holder); + + /** The card was let go, moved or not. */ + void onDragEnded(@NonNull RecyclerView.ViewHolder holder); + + void onSwiped(@NonNull RecyclerView.ViewHolder holder, int direction); + } + + /** Height of the band at the top and bottom of the visible list that scrolls it, in dp. */ + static final int EDGE_ZONE_DP = 72; + + /** Fastest edge scroll, in dp per frame, reached at the very edge. */ + static final int EDGE_MAX_SPEED_DP = 16; + + private final Host host; + @Nullable private ItemTouchHelper helper; + + // The edge scroll: the list, the card held, which way (-1 up, +1 down, 0 none) and how deep + // into the band the card is (0..1). + @Nullable private RecyclerView edgeList; + @Nullable private RecyclerView.ViewHolder edgeCard; + private int edgeDirection; + private float edgeDepth; + private boolean edgeScrolling; + private final Rect visible = new Rect(); + private final Runnable edgeStep = this::edgeStep; + + public NoteDragCallback(@NonNull Host host) { + super(0, ItemTouchHelper.LEFT | ItemTouchHelper.RIGHT); + this.host = host; + } + + /** + * The helper this callback belongs to; needed to scroll the list when a card is held near its + * top or bottom edge. + */ + public void attachHelper(@NonNull ItemTouchHelper helper) { + this.helper = helper; + } + + @Override + public boolean isItemViewSwipeEnabled() { + return host.canSwipe(); + } + + @Override + public boolean isLongPressDragEnabled() { + // Started from the card's own long press, which also decides whether its menu opens. + return false; + } + + @Override + public int getDragDirs( + @NonNull RecyclerView recyclerView, @NonNull RecyclerView.ViewHolder viewHolder) { + if (!host.canDrag()) return 0; + int vertical = ItemTouchHelper.UP | ItemTouchHelper.DOWN; + return spanCount(recyclerView) > 1 + ? vertical | ItemTouchHelper.START | ItemTouchHelper.END + : vertical; + } + + private static int spanCount(RecyclerView recyclerView) { + return recyclerView.getLayoutManager() instanceof NotesGridLayoutManager grid + ? grid.getSpanCount() + : 1; + } + + @Override + public boolean canDropOver( + @NonNull RecyclerView recyclerView, + @NonNull RecyclerView.ViewHolder current, + @NonNull RecyclerView.ViewHolder target) { + return host.canTrade( + current.getBindingAdapterPosition(), target.getBindingAdapterPosition()); + } + + @Nullable + @Override + public RecyclerView.ViewHolder chooseDropTarget( + @NonNull RecyclerView.ViewHolder selected, + @NonNull List dropTargets, + int curX, + int curY) { + int from = selected.getBindingAdapterPosition(); + if (from == RecyclerView.NO_POSITION) return null; + List cards = new ArrayList<>(dropTargets.size()); + for (RecyclerView.ViewHolder target : dropTargets) { + int position = target.getBindingAdapterPosition(); + if (position != RecyclerView.NO_POSITION) cards.add(card(position, target)); + } + DragTarget.Card chosen = + DragTarget.choose( + card(from, selected), + curX + selected.itemView.getWidth() / 2, + curY + selected.itemView.getHeight() / 2, + cards); + if (chosen == null) return null; + for (RecyclerView.ViewHolder target : dropTargets) { + if (target.getBindingAdapterPosition() == chosen.position()) return target; + } + return null; + } + + private static DragTarget.Card card(int position, RecyclerView.ViewHolder holder) { + return new DragTarget.Card( + position, + holder.itemView.getLeft(), + holder.itemView.getTop(), + holder.itemView.getRight(), + holder.itemView.getBottom()); + } + + @Override + public boolean onMove( + @NonNull RecyclerView recyclerView, + @NonNull RecyclerView.ViewHolder viewHolder, + @NonNull RecyclerView.ViewHolder target) { + int from = viewHolder.getBindingAdapterPosition(); + int to = target.getBindingAdapterPosition(); + if (!host.isDragging() + || from == RecyclerView.NO_POSITION + || to == RecyclerView.NO_POSITION + || !host.canTrade(from, to)) { + return false; + } + RecyclerView.LayoutManager lm = recyclerView.getLayoutManager(); + if (lm instanceof NotesGridLayoutManager grid) { + // The card takes the other card's place: its top there when moving up the list, + // its bottom when moving down. + boolean down = to > from; + grid.keepInView( + to, + down + ? lm.getDecoratedBottom(target.itemView) + : lm.getDecoratedTop(target.itemView), + down); + } + host.move(from, to); + return true; + } + + @Override + public void onMoved( + @NonNull RecyclerView recyclerView, + @NonNull RecyclerView.ViewHolder viewHolder, + int fromPos, + @NonNull RecyclerView.ViewHolder target, + int toPos, + int x, + int y) { + // The default scrolls to the new position when the other card was at an edge; with a + // StaggeredGridLayoutManager that scroll is resolved against the layout before the move + // and hid the dragged card. NotesGridLayoutManager keeps it on screen instead. + } + + /** + * The helper's own edge scroll starts only once the card crosses the list's bottom, which is + * below the screen while the search bar is expanded: a card shorter than that overhang could + * never reach it. The list is scrolled from {@link #onChildDraw} instead, when the card enters + * a band at the visible top or bottom, whatever its height. + */ + @Override + public int interpolateOutOfBoundsScroll( + @NonNull RecyclerView recyclerView, + int viewSize, + int viewSizeOutOfBounds, + int totalSize, + long msSinceStartScroll) { + return helper != null + ? 0 + : super.interpolateOutOfBoundsScroll( + recyclerView, viewSize, viewSizeOutOfBounds, totalSize, msSinceStartScroll); + } + + @Override + public void onChildDraw( + @NonNull Canvas c, + @NonNull RecyclerView recyclerView, + @NonNull RecyclerView.ViewHolder viewHolder, + float dX, + float dY, + int actionState, + boolean isCurrentlyActive) { + super.onChildDraw(c, recyclerView, viewHolder, dX, dY, actionState, isCurrentlyActive); + if (actionState == ItemTouchHelper.ACTION_STATE_DRAG && isCurrentlyActive) { + followEdges(recyclerView, viewHolder, dY); + } else if (viewHolder == edgeCard) { + edgeDirection = 0; + } + } + + /** Starts, steers or stops the edge scroll for the card held at {@code dY} from its place. */ + private void followEdges(RecyclerView list, RecyclerView.ViewHolder card, float dY) { + edgeList = list; + edgeCard = card; + edgeDirection = 0; + if (helper == null || !list.getLocalVisibleRect(visible)) return; + int top = Math.max(visible.top, list.getPaddingTop()); + int bottom = visible.bottom; + float density = list.getResources().getDisplayMetrics().density; + float zone = Math.min(EDGE_ZONE_DP * density, (bottom - top) / 4f); + if (zone <= 0) return; + View view = card.itemView; + float center = view.getTop() + dY + view.getHeight() / 2f; + if (center > bottom - zone && list.canScrollVertically(1)) { + edgeDirection = 1; + edgeDepth = Math.min(1f, (center - (bottom - zone)) / zone); + } else if (center < top + zone && list.canScrollVertically(-1)) { + edgeDirection = -1; + edgeDepth = Math.min(1f, (top + zone - center) / zone); + } + if (edgeDirection != 0 && !edgeScrolling) { + edgeScrolling = true; + ViewCompat.postOnAnimation(list, edgeStep); + } + } + + private void edgeStep() { + RecyclerView list = edgeList; + RecyclerView.ViewHolder card = edgeCard; + if (list == null || card == null || helper == null || edgeDirection == 0) { + edgeScrolling = false; + return; + } + float density = list.getResources().getDisplayMetrics().density; + int speed = + Math.max(1, Math.round(EDGE_MAX_SPEED_DP * density * (0.2f + 0.8f * edgeDepth))); + list.scrollBy(0, edgeDirection * speed); + // The card stays under the finger while the list moves; it may now cover another card. + ItemTouchHelperAccess.moveIfNecessary(helper, card); + // Drawn again, the card reports where it is now and steers the next step. + edgeDirection = 0; + list.invalidate(); + ViewCompat.postOnAnimation(list, edgeStep); + } + + @Override + public void onSelectedChanged(@Nullable RecyclerView.ViewHolder viewHolder, int actionState) { + super.onSelectedChanged(viewHolder, actionState); + if (actionState == ItemTouchHelper.ACTION_STATE_DRAG && viewHolder != null) { + host.onDragStarted(viewHolder); + } + } + + @Override + public void clearView( + @NonNull RecyclerView recyclerView, @NonNull RecyclerView.ViewHolder viewHolder) { + super.clearView(recyclerView, viewHolder); + if (viewHolder == edgeCard) { + edgeCard = null; + edgeDirection = 0; + } + viewHolder.itemView.setAlpha(1f); + if (host.isDragging()) host.onDragEnded(viewHolder); + } + + @Override + public void onSwiped(@NonNull RecyclerView.ViewHolder viewHolder, int direction) { + host.onSwiped(viewHolder, direction); + } + + /** + * Slides a card that was swiped off the screen but stays in the list (it was selected, not + * removed) back into its place. + * + *

{@link ItemTouchHelper} keeps drawing a swiped card where the swipe left it until its view + * is detached from the list. That used to happen when the card was rebound: the change was + * shown with a second copy of the card and the swiped one went away. Without change animations + * the same view is rebound in place, so the card stayed off the screen. Here the helper lets go + * of it as if it was detached, and the card is animated back. + */ + public static void returnSwipedCard( + @NonNull ItemTouchHelper helper, @NonNull RecyclerView.ViewHolder holder) { + View card = holder.itemView; + float x = card.getTranslationX(); + float alpha = card.getAlpha(); + helper.onChildViewDetachedFromWindow(card); + // Its own animator: the list's item animator cancels the view's property animations + // when the card is rebound for its new selection state. + ValueAnimator back = ValueAnimator.ofFloat(1f, 0f); + back.setDuration(SWIPE_RETURN_MS); + back.setInterpolator(new DecelerateInterpolator()); + back.addUpdateListener( + animation -> { + float left = (float) animation.getAnimatedValue(); + card.setTranslationX(x * left); + card.setAlpha(1f - (1f - alpha) * left); + }); + back.start(); + } + + static final long SWIPE_RETURN_MS = 220; +} diff --git a/app/src/main/java/com/pasich/mynotes/utils/recycler/NoteListTransition.java b/app/src/main/java/com/pasich/mynotes/utils/recycler/NoteListTransition.java new file mode 100644 index 00000000..db1ae3f5 --- /dev/null +++ b/app/src/main/java/com/pasich/mynotes/utils/recycler/NoteListTransition.java @@ -0,0 +1,49 @@ +package com.pasich.mynotes.utils.recycler; + +import com.pasich.mynotes.data.model.Note; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +/** Decides how the notes grid should move from one list to the next. */ +public final class NoteListTransition { + + private NoteListTransition() {} + + /** + * True when the change should be shown as one cross-fade of the whole list instead of per-card + * animations. + * + *

A reorder always qualifies: an edited note jumping to the top would otherwise slide over + * every card in between. In a grid with more than one column, adding or removing a card also + * qualifies, because it moves the cards after it into the other column and those moves cut + * diagonally across their neighbours. Text-only changes, and anything involving an empty list, + * never do. + */ + public static boolean needsCrossfade(List previous, List next, int spanCount) { + if (previous.isEmpty() || next.isEmpty()) return false; + + Set previousIds = idsOf(previous); + Set nextIds = idsOf(next); + boolean membershipChanged = !previousIds.equals(nextIds); + if (membershipChanged && spanCount > 1) return true; + + return !commonOrder(previous, nextIds).equals(commonOrder(next, previousIds)); + } + + private static Set idsOf(List notes) { + Set ids = new HashSet<>(notes.size() * 2); + for (Note n : notes) ids.add(n.getId()); + return ids; + } + + /** The ids of {@code notes} that also appear in {@code keep}, in list order. */ + private static List commonOrder(List notes, Set keep) { + List order = new ArrayList<>(notes.size()); + for (Note n : notes) { + if (keep.contains(n.getId())) order.add(n.getId()); + } + return order; + } +} diff --git a/app/src/main/java/com/pasich/mynotes/utils/recycler/NotesGridLayoutManager.java b/app/src/main/java/com/pasich/mynotes/utils/recycler/NotesGridLayoutManager.java new file mode 100644 index 00000000..df36e1de --- /dev/null +++ b/app/src/main/java/com/pasich/mynotes/utils/recycler/NotesGridLayoutManager.java @@ -0,0 +1,79 @@ +package com.pasich.mynotes.utils.recycler; + +import android.view.View; +import androidx.recyclerview.widget.RecyclerView; +import androidx.recyclerview.widget.StaggeredGridLayoutManager; + +/** + * The notes list and grid, which also keeps a dragged card on screen as it moves. + * + *

After a card moves under the finger, {@link StaggeredGridLayoutManager} lays the list out + * again from the first card it had on screen. A card dragged onto the first one therefore ended up + * above the screen: the list seemed to scroll away from it, and the drag was dropped because its + * view left the list. {@code LinearLayoutManager} handles this with {@code prepareForDrop}; this + * layout manager does the same once the move is laid out, scrolling only as far as needed to bring + * the card back where it was dropped. (Asking for a scroll to a position instead would be resolved + * against the layout before the move and land wrong.) + */ +public class NotesGridLayoutManager extends StaggeredGridLayoutManager { + + private static final int MAX_STEPS = 8; + + private int keepPosition = RecyclerView.NO_POSITION; + private int keepEdge; + private boolean keepBottom; + + public NotesGridLayoutManager(int spanCount) { + super(spanCount, VERTICAL); + } + + /** + * After the next layout, if the card at {@code position} is not fully on screen, scrolls so its + * top (or, with {@code alignBottom}, its bottom) is at {@code edge}, as close as the list + * allows. + */ + public void keepInView(int position, int edge, boolean alignBottom) { + keepPosition = position; + keepEdge = edge; + keepBottom = alignBottom; + } + + @Override + public void onLayoutChildren(RecyclerView.Recycler recycler, RecyclerView.State state) { + super.onLayoutChildren(recycler, state); + if (state.isPreLayout() || keepPosition == RecyclerView.NO_POSITION) return; + int position = keepPosition; + keepPosition = RecyclerView.NO_POSITION; + if (position < state.getItemCount()) bringIntoView(position, recycler, state); + } + + private void bringIntoView( + int position, RecyclerView.Recycler recycler, RecyclerView.State state) { + int top = getPaddingTop(); + int bottom = getHeight() - getPaddingBottom(); + for (int step = 0; step < MAX_STEPS; step++) { + View child = findViewByPosition(position); + if (child == null) { + // Not laid out at all: it went above or below the screen. Bring it closer. + int[] first = findFirstVisibleItemPositions(null); + int firstShown = Integer.MAX_VALUE; + for (int p : first) + if (p != RecyclerView.NO_POSITION) firstShown = Math.min(firstShown, p); + int direction = position < firstShown ? -1 : 1; + if (scrollVerticallyBy(direction * Math.max(1, bottom - top), recycler, state) + == 0) { + return; + } + continue; + } + int childTop = getDecoratedTop(child); + int childBottom = getDecoratedBottom(child); + boolean fullyShown = childTop >= top && childBottom <= bottom; + boolean tallerThanScreen = childBottom - childTop > bottom - top; + if (fullyShown || (tallerThanScreen && childTop < bottom && childBottom > top)) return; + int delta = keepBottom ? childBottom - keepEdge : childTop - keepEdge; + if (delta != 0) scrollVerticallyBy(delta, recycler, state); + return; + } + } +} diff --git a/app/src/main/java/com/pasich/mynotes/utils/recycler/NotesItemAnimator.java b/app/src/main/java/com/pasich/mynotes/utils/recycler/NotesItemAnimator.java new file mode 100644 index 00000000..717624cb --- /dev/null +++ b/app/src/main/java/com/pasich/mynotes/utils/recycler/NotesItemAnimator.java @@ -0,0 +1,76 @@ +package com.pasich.mynotes.utils.recycler; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.recyclerview.widget.DefaultItemAnimator; +import androidx.recyclerview.widget.RecyclerView; +import java.util.function.BooleanSupplier; + +/** + * Item animator for the notes grid. + * + *

Change animations are off: a card whose text changed is rebound in place instead of being + * cross-faded with a second copy of itself, which in a staggered grid with a different height + * showed up as one note drawn over another. Whenever {@code allowed} says no (the screen is not + * visible, a return transition is running, or the whole list is being swapped behind a fade) every + * change is applied at once, without animation. + */ +public class NotesItemAnimator extends DefaultItemAnimator { + + private final BooleanSupplier allowed; + + public NotesItemAnimator(@NonNull BooleanSupplier allowed) { + this.allowed = allowed; + setSupportsChangeAnimations(false); + } + + @Override + public boolean animateAppearance( + @NonNull RecyclerView.ViewHolder holder, + @Nullable ItemHolderInfo preLayoutInfo, + @NonNull ItemHolderInfo postLayoutInfo) { + if (skip(holder)) return false; + return super.animateAppearance(holder, preLayoutInfo, postLayoutInfo); + } + + @Override + public boolean animateDisappearance( + @NonNull RecyclerView.ViewHolder holder, + @NonNull ItemHolderInfo preLayoutInfo, + @Nullable ItemHolderInfo postLayoutInfo) { + if (skip(holder)) return false; + return super.animateDisappearance(holder, preLayoutInfo, postLayoutInfo); + } + + @Override + public boolean animatePersistence( + @NonNull RecyclerView.ViewHolder holder, + @NonNull ItemHolderInfo preLayoutInfo, + @NonNull ItemHolderInfo postLayoutInfo) { + if (skip(holder)) return false; + return super.animatePersistence(holder, preLayoutInfo, postLayoutInfo); + } + + @Override + public boolean animateChange( + @NonNull RecyclerView.ViewHolder oldHolder, + @NonNull RecyclerView.ViewHolder newHolder, + @NonNull ItemHolderInfo preLayoutInfo, + @NonNull ItemHolderInfo postLayoutInfo) { + if (!allowed.getAsBoolean()) { + dispatchAnimationFinished(oldHolder); + if (newHolder != oldHolder) dispatchAnimationFinished(newHolder); + return false; + } + return super.animateChange(oldHolder, newHolder, preLayoutInfo, postLayoutInfo); + } + + private boolean skip(RecyclerView.ViewHolder holder) { + if (allowed.getAsBoolean()) return false; + holder.itemView.setAlpha(1f); + holder.itemView.setTranslationX(0f); + holder.itemView.setTranslationY(0f); + dispatchAnimationFinished(holder); + return true; + } +} diff --git a/app/src/main/java/com/pasich/mynotes/utils/recycler/SwipeToListNotesCallback.java b/app/src/main/java/com/pasich/mynotes/utils/recycler/SwipeToListNotesCallback.java index f87cbe52..13dcf360 100644 --- a/app/src/main/java/com/pasich/mynotes/utils/recycler/SwipeToListNotesCallback.java +++ b/app/src/main/java/com/pasich/mynotes/utils/recycler/SwipeToListNotesCallback.java @@ -29,8 +29,11 @@ public void onChildDraw( int actionState, boolean isCurrentlyActive) { - final float alpha = 1.0f - Math.abs(dX) / (float) viewHolder.itemView.getWidth(); - viewHolder.itemView.setAlpha(alpha); + // Fades a card as it is swiped away; a dragged card moves sideways too, and stays opaque. + if (actionState == ItemTouchHelper.ACTION_STATE_SWIPE) { + final float alpha = 1.0f - Math.abs(dX) / (float) viewHolder.itemView.getWidth(); + viewHolder.itemView.setAlpha(alpha); + } super.onChildDraw(c, recyclerView, viewHolder, dX, dY, actionState, isCurrentlyActive); } diff --git a/app/src/main/java/com/pasich/mynotes/utils/recycler/diffutil/NoteDiff.java b/app/src/main/java/com/pasich/mynotes/utils/recycler/diffutil/NoteDiff.java index 61bec9a0..d9b4cc44 100644 --- a/app/src/main/java/com/pasich/mynotes/utils/recycler/diffutil/NoteDiff.java +++ b/app/src/main/java/com/pasich/mynotes/utils/recycler/diffutil/NoteDiff.java @@ -22,12 +22,19 @@ public boolean areItemsTheSame(@NonNull Note oldItem, @NonNull Note newItem) { @Override public boolean areContentsTheSame(@NonNull Note oldItem, @NonNull Note newItem) { + return sameContent(oldItem, newItem); + } + + /** True when both notes would render the same card. */ + public static boolean sameContent(@NonNull Note oldItem, @NonNull Note newItem) { return oldItem.getId() == newItem.getId() - && oldItem.getTitle().equals(newItem.getTitle()) - && oldItem.getValue().equals(newItem.getValue()) - && oldItem.getTag().equals(newItem.getTag()) + && Objects.equals(oldItem.getTitle(), newItem.getTitle()) + && Objects.equals(oldItem.getValue(), newItem.getValue()) + && Objects.equals(oldItem.getTag(), newItem.getTag()) && oldItem.getDate() == newItem.getDate() && oldItem.isPinned() == newItem.isPinned() - && Objects.equals(oldItem.getReminderTime(), newItem.getReminderTime()); + && Objects.equals(oldItem.getReminderTime(), newItem.getReminderTime()) + && Objects.equals(oldItem.getReminderRepeat(), newItem.getReminderRepeat()) + && Objects.equals(oldItem.getAttachments(), newItem.getAttachments()); } } diff --git a/app/src/main/java/com/pasich/mynotes/utils/recycler/payloads/NotePayloads.java b/app/src/main/java/com/pasich/mynotes/utils/recycler/payloads/NotePayloads.java index 6d528260..51596701 100644 --- a/app/src/main/java/com/pasich/mynotes/utils/recycler/payloads/NotePayloads.java +++ b/app/src/main/java/com/pasich/mynotes/utils/recycler/payloads/NotePayloads.java @@ -2,4 +2,7 @@ public class NotePayloads { public static final int PAYLOAD_SELECTION = 22; + + /** The custom order was turned on or off; only the cards' accessibility actions change. */ + public static final int PAYLOAD_REORDER = 23; } diff --git a/app/src/main/java/com/pasich/mynotes/utils/reminder/ReminderDraft.java b/app/src/main/java/com/pasich/mynotes/utils/reminder/ReminderDraft.java new file mode 100644 index 00000000..74bb39b2 --- /dev/null +++ b/app/src/main/java/com/pasich/mynotes/utils/reminder/ReminderDraft.java @@ -0,0 +1,81 @@ +package com.pasich.mynotes.utils.reminder; + +import android.os.Bundle; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import com.pasich.mynotes.data.model.RepeatRule; +import java.util.Calendar; +import java.util.TimeZone; + +/** + * What the reminder sheet holds before it is saved: the chosen time, the repeat, the repeated + * notification and a date picked while its time is still being chosen. Kept in the sheet's saved + * state so a rotation keeps all of it. + */ +public final class ReminderDraft { + + private static final String KEY_TIME = "draft.time"; + private static final String KEY_REPEAT_CHIP = "draft.repeatChip"; + private static final String KEY_CUSTOM_RULE = "draft.customRule"; + private static final String KEY_INTERVAL = "draft.intervalMinutes"; + private static final String KEY_PICKED_DATE = "draft.pickedDate"; + + /** The chosen time, or null while none is chosen. */ + @Nullable public Long time; + + /** The checked repeat chip, 0 for the layout's default. */ + public int repeatChipId; + + /** The rule behind the Custom chip, or null while it has none. */ + @Nullable public RepeatRule customRule; + + /** Minutes between repeated notifications, 0 for none. */ + public int intervalMinutes; + + /** A day picked in the date picker (UTC midnight) whose time is being chosen, or null. */ + @Nullable public Long pickedDateUtc; + + public void save(@NonNull Bundle out) { + if (time != null) out.putLong(KEY_TIME, time); + out.putInt(KEY_REPEAT_CHIP, repeatChipId); + if (customRule != null) out.putString(KEY_CUSTOM_RULE, customRule.serialize()); + out.putInt(KEY_INTERVAL, intervalMinutes); + if (pickedDateUtc != null) out.putLong(KEY_PICKED_DATE, pickedDateUtc); + } + + /** The draft kept in {@code in}, or null when there is none (the sheet opens fresh). */ + @Nullable + public static ReminderDraft restore(@Nullable Bundle in) { + if (in == null || !in.containsKey(KEY_REPEAT_CHIP)) return null; + ReminderDraft draft = new ReminderDraft(); + if (in.containsKey(KEY_TIME)) draft.time = in.getLong(KEY_TIME); + draft.repeatChipId = in.getInt(KEY_REPEAT_CHIP); + String rule = in.getString(KEY_CUSTOM_RULE); + if (rule != null) { + RepeatRule parsed = RepeatRule.parse(rule); + draft.customRule = parsed.isRepeating() ? parsed : null; + } + draft.intervalMinutes = in.getInt(KEY_INTERVAL); + if (in.containsKey(KEY_PICKED_DATE)) draft.pickedDateUtc = in.getLong(KEY_PICKED_DATE); + return draft; + } + + /** + * The moment for a day from the date picker, which gives UTC midnight of that day, at a time of + * day in {@code zone}. + */ + public static long at(long pickedDateUtc, int hour, int minute, @NonNull TimeZone zone) { + Calendar day = Calendar.getInstance(TimeZone.getTimeZone("UTC")); + day.setTimeInMillis(pickedDateUtc); + Calendar local = Calendar.getInstance(zone); + local.clear(); + local.set( + day.get(Calendar.YEAR), + day.get(Calendar.MONTH), + day.get(Calendar.DAY_OF_MONTH), + hour, + minute, + 0); + return local.getTimeInMillis(); + } +} diff --git a/app/src/main/java/com/pasich/mynotes/utils/reminder/ReminderManager.java b/app/src/main/java/com/pasich/mynotes/utils/reminder/ReminderManager.java index 95620349..93943290 100644 --- a/app/src/main/java/com/pasich/mynotes/utils/reminder/ReminderManager.java +++ b/app/src/main/java/com/pasich/mynotes/utils/reminder/ReminderManager.java @@ -4,15 +4,34 @@ import android.app.PendingIntent; import android.content.Context; import android.content.Intent; +import android.net.Uri; import android.os.Build; +import android.provider.Settings; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import com.pasich.mynotes.data.model.Note; -import com.pasich.mynotes.data.model.ReminderRepeat; import com.pasich.mynotes.ui.receiver.ReminderReceiver; -import java.util.Calendar; import java.util.List; -/** Schedules and cancels exact AlarmManager reminders for notes. */ -public class ReminderManager { +/** + * Arms and cancels the alarms behind a note reminder. + * + *

A note has up to three independent alarms, told apart by their intent action so that none + * replaces another: + * + *

    + *
  • the scheduled occurrence (no action, as in earlier versions, so an alarm armed before an + * update is still the one that is replaced or cancelled); + *
  • the "repeat notification" nag ({@link #ACTION_NAG}), which re-posts the notification every + * few minutes until it is dismissed and never moves the schedule; + *
  • a snooze ({@link #ACTION_SNOOZE}), remembered in {@link SnoozeStore} so a reboot keeps it. + *
+ * + *

When the system does not allow exact alarms the reminder is still armed, as an inexact alarm + * that may arrive a little late, rather than silently dropped; the caller is told so it can ask for + * the permission. + */ +public final class ReminderManager { public static final String EXTRA_NOTE_ID = "noteId"; public static final String EXTRA_NOTE_TITLE = "noteTitle"; @@ -20,65 +39,86 @@ public class ReminderManager { public static final String EXTRA_NOTE_REPEAT = "noteRepeat"; public static final String EXTRA_NOTE_INTERVAL_MINUTES = "intervalMinutes"; - /** Schedules an exact alarm for the given note's reminder time. */ - public static void scheduleReminder(Context ctx, Note note) { - if (note.getReminderTime() == null) return; + /** The time the alarm was armed for, so the receiver can tell a stale alarm from a due one. */ + public static final String EXTRA_SCHEDULED_AT = "scheduledAt"; - AlarmManager alarmManager = (AlarmManager) ctx.getSystemService(Context.ALARM_SERVICE); - if (alarmManager == null) return; + public static final String ACTION_NAG = "com.pasich.mynotes.ACTION_REMINDER_NAG"; + public static final String ACTION_SNOOZE = "com.pasich.mynotes.ACTION_REMINDER_SNOOZE"; - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - if (!alarmManager.canScheduleExactAlarms()) return; - } + private ReminderManager() {} - alarmManager.setExactAndAllowWhileIdle( - AlarmManager.RTC_WAKEUP, note.getReminderTime(), buildPendingIntent(ctx, note)); + /** + * Arms the note's next occurrence at {@link Note#getReminderTime()}. A time already passed + * fires at once, which is how a missed occurrence is caught up. + * + * @return true when the alarm is exact, false when it was armed inexactly or not at all + */ + public static boolean scheduleReminder(@NonNull Context ctx, @NonNull Note note) { + Long time = note.getReminderTime(); + if (time == null) return false; + Intent intent = baseIntent(ctx, note.getId()); + intent.putExtra(EXTRA_NOTE_TITLE, note.getTitle()); + intent.putExtra(EXTRA_NOTE_PREVIEW, note.getValuePreview()); + intent.putExtra(EXTRA_NOTE_REPEAT, note.getReminderRepeat()); + intent.putExtra(EXTRA_NOTE_INTERVAL_MINUTES, note.getReminderIntervalMinutes()); + intent.putExtra(EXTRA_SCHEDULED_AT, time); + return arm(ctx, time, broadcast(ctx, note.getId(), intent)); } - /** Cancels a previously scheduled alarm for the given note ID. */ - public static void cancelReminder(Context ctx, int noteId) { - AlarmManager alarmManager = (AlarmManager) ctx.getSystemService(Context.ALARM_SERVICE); - if (alarmManager == null) return; - Intent intent = new Intent(ctx, ReminderReceiver.class); - PendingIntent pi = - PendingIntent.getBroadcast( - ctx, - noteId, - intent, - PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE); - alarmManager.cancel(pi); - pi.cancel(); + /** Re-posts the notification after {@code intervalMinutes} until it is dismissed. */ + public static boolean scheduleNag( + @NonNull Context ctx, int noteId, int intervalMinutes, long now) { + if (intervalMinutes <= 0) return false; + Intent intent = baseIntent(ctx, noteId).setAction(ACTION_NAG); + intent.putExtra(EXTRA_NOTE_INTERVAL_MINUTES, intervalMinutes); + return arm(ctx, now + intervalMinutes * 60_000L, broadcast(ctx, noteId, intent)); } - /** Re-schedules alarms for all supplied notes (e.g. after reboot). */ - public static void rescheduleAll(Context ctx, List notes) { - for (Note note : notes) { - scheduleReminder(ctx, note); - } + /** Stops the "repeat notification" cycle; the schedule itself is untouched. */ + public static void cancelNag(@NonNull Context ctx, int noteId) { + cancel(ctx, noteId, ACTION_NAG); } - /** Computes the next trigger time by advancing {@code from} by one repeat unit. */ - public static long computeNextTime(long from, ReminderRepeat repeat) { - Calendar cal = Calendar.getInstance(); - cal.setTimeInMillis(from); - switch (repeat) { - case DAILY: - cal.add(Calendar.DAY_OF_YEAR, 1); - break; - case WEEKLY: - cal.add(Calendar.WEEK_OF_YEAR, 1); - break; - case MONTHLY: - cal.add(Calendar.MONTH, 1); - break; - default: - break; - } - return cal.getTimeInMillis(); + /** + * Shows the reminder again at {@code time}, without touching the repeat schedule, and remembers + * it so a reboot does not lose it. + */ + public static boolean scheduleSnooze( + @NonNull Context ctx, int noteId, long time, int intervalMinutes) { + new SnoozeStore(ctx).put(noteId, time, intervalMinutes); + return armSnooze(ctx, noteId, time, intervalMinutes); + } + + static boolean armSnooze(@NonNull Context ctx, int noteId, long time, int intervalMinutes) { + Intent intent = baseIntent(ctx, noteId).setAction(ACTION_SNOOZE); + intent.putExtra(EXTRA_NOTE_INTERVAL_MINUTES, intervalMinutes); + intent.putExtra(EXTRA_SCHEDULED_AT, time); + return arm(ctx, time, broadcast(ctx, noteId, intent)); + } + + /** + * Cancels the scheduled occurrence only. A notification already shown keeps its repeat cycle + * and its snooze: they answer something that already happened. + */ + public static void cancelScheduled(@NonNull Context ctx, int noteId) { + cancel(ctx, noteId, null); + } + + /** Cancels everything armed for the note: the schedule, the nag and any snooze. */ + public static void cancelReminder(@NonNull Context ctx, int noteId) { + cancel(ctx, noteId, null); + cancel(ctx, noteId, ACTION_NAG); + cancel(ctx, noteId, ACTION_SNOOZE); + new SnoozeStore(ctx).remove(noteId); } - /** Returns whether the app has permission to schedule exact alarms. */ - public static boolean canScheduleExact(Context ctx) { + /** Re-arms the given notes' scheduled occurrences. */ + public static void rescheduleAll(@NonNull Context ctx, @NonNull List notes) { + for (Note note : notes) scheduleReminder(ctx, note); + } + + /** Whether the app may schedule exact alarms ("Alarms & reminders" on Android 12+). */ + public static boolean canScheduleExact(@NonNull Context ctx) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { AlarmManager am = (AlarmManager) ctx.getSystemService(Context.ALARM_SERVICE); return am != null && am.canScheduleExactAlarms(); @@ -86,16 +126,46 @@ public static boolean canScheduleExact(Context ctx) { return true; } - private static PendingIntent buildPendingIntent(Context ctx, Note note) { - Intent intent = new Intent(ctx, ReminderReceiver.class); - intent.putExtra(EXTRA_NOTE_ID, note.getId()); - intent.putExtra(EXTRA_NOTE_TITLE, note.getTitle()); - intent.putExtra(EXTRA_NOTE_PREVIEW, note.getValuePreview()); - intent.putExtra(EXTRA_NOTE_REPEAT, note.getReminderRepeat()); - intent.putExtra(EXTRA_NOTE_INTERVAL_MINUTES, note.getReminderIntervalMinutes()); + /** + * The system screen where the user allows exact alarms for this app, or null below Android 12, + * where no permission is needed. + */ + @Nullable + public static Intent exactAlarmSettingsIntent(@NonNull Context ctx) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) return null; + return new Intent(Settings.ACTION_REQUEST_SCHEDULE_EXACT_ALARM) + .setData(Uri.fromParts("package", ctx.getPackageName(), null)); + } + + static boolean arm(@NonNull Context ctx, long time, @NonNull PendingIntent pi) { + AlarmManager am = (AlarmManager) ctx.getSystemService(Context.ALARM_SERVICE); + if (am == null) return false; + if (canScheduleExact(ctx)) { + am.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, time, pi); + return true; + } + // Allowed without the permission; the system may deliver it some minutes late. + am.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, time, pi); + return false; + } + + private static void cancel(@NonNull Context ctx, int noteId, @Nullable String action) { + AlarmManager am = (AlarmManager) ctx.getSystemService(Context.ALARM_SERVICE); + Intent intent = baseIntent(ctx, noteId); + if (action != null) intent.setAction(action); + PendingIntent pi = broadcast(ctx, noteId, intent); + if (am != null) am.cancel(pi); + pi.cancel(); + } + + private static Intent baseIntent(@NonNull Context ctx, int noteId) { + return new Intent(ctx, ReminderReceiver.class).putExtra(EXTRA_NOTE_ID, noteId); + } + + private static PendingIntent broadcast(@NonNull Context ctx, int noteId, Intent intent) { return PendingIntent.getBroadcast( ctx, - note.getId(), + noteId, intent, PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE); } diff --git a/app/src/main/java/com/pasich/mynotes/utils/reminder/ReminderRescheduler.java b/app/src/main/java/com/pasich/mynotes/utils/reminder/ReminderRescheduler.java new file mode 100644 index 00000000..12998f94 --- /dev/null +++ b/app/src/main/java/com/pasich/mynotes/utils/reminder/ReminderRescheduler.java @@ -0,0 +1,116 @@ +package com.pasich.mynotes.utils.reminder; + +import android.content.Context; +import androidx.annotation.NonNull; +import androidx.annotation.WorkerThread; +import com.pasich.mynotes.data.database.AppDatabase; +import com.pasich.mynotes.data.model.Note; +import com.pasich.mynotes.data.model.RepeatRule; +import com.pasich.mynotes.data.model.Task; +import dagger.hilt.android.qualifiers.ApplicationContext; +import java.util.Collection; +import java.util.List; +import javax.inject.Inject; +import javax.inject.Singleton; + +/** + * Brings the armed alarms back in line with the reminders stored in the database. + * + *

Alarms do not survive a reboot, an app update, a force stop or the "Alarms & reminders" + * permission being switched off and on, and a reminder changed on another device arrives by sync + * without any alarm. This re-arms from what is stored, at boot, after an update, when the + * permission is granted, at app start and after a sync. + * + *

An occurrence that passed while nothing was armed is armed for "now": the receiver posts one + * notification for it and moves a repeating reminder to its next future occurrence counted from the + * anchor, however many periods were missed. Only the receiver advances a schedule, so this running + * at the same moment as a due alarm cannot skip or double it. A one-time reminder missed by more + * than {@link #MISSED_ONE_TIME_GRACE_MS} is left alone, as before, rather than surfacing something + * long out of date. + */ +@Singleton +public class ReminderRescheduler { + + static final long MISSED_ONE_TIME_GRACE_MS = 24L * 60 * 60 * 1000; + + /** What a full re-arm did. */ + public static final class Result { + public final int armed; + public final int inexact; + + Result(int armed, int inexact) { + this.armed = armed; + this.inexact = inexact; + } + } + + private final Context context; + private final AppDatabase database; + + @Inject + public ReminderRescheduler(@ApplicationContext Context context, AppDatabase database) { + this.context = context.getApplicationContext(); + this.database = database; + } + + /** Re-arms every note reminder, task reminder and remembered snooze. */ + @WorkerThread + @NonNull + public synchronized Result rescheduleAll(long now) { + int armed = 0; + int inexact = 0; + for (Note note : database.noteDao().getNotesWithRemindersSync()) { + if (!shouldArm(note, now)) continue; + armed++; + if (!ReminderManager.scheduleReminder(context, note)) inexact++; + } + SnoozeStore snoozes = new SnoozeStore(context); + for (SnoozeStore.Entry snooze : snoozes.all()) { + Note note = database.noteDao().getNoteSync(snooze.noteId); + if (note == null || note.isTrash()) { + snoozes.remove(snooze.noteId); + continue; + } + armed++; + if (!ReminderManager.armSnooze( + context, snooze.noteId, snooze.time, snooze.intervalMinutes)) inexact++; + } + List tasks = database.taskDao().getTasksWithRemindersSync(); + TaskReminderManager.rescheduleAll(context, tasks); + return new Result(armed, inexact); + } + + /** + * Arms or cancels the given notes' reminders to match the database, after sync wrote them: a + * reminder set, moved or turned off on another device, or a note deleted there. + */ + @WorkerThread + public synchronized void reconcileNotes(@NonNull Collection noteIds, long now) { + for (int noteId : noteIds) { + Note note = database.noteDao().getNoteSync(noteId); + if (note == null || note.isTrash()) { + ReminderManager.cancelReminder(context, noteId); + } else if (note.getReminderTime() == null) { + // Turned off, or a one-time reminder that already fired: a snooze or repeating + // notification of what was already shown stays. + ReminderManager.cancelScheduled(context, noteId); + } else if (shouldArm(note, now)) { + ReminderManager.scheduleReminder(context, note); + } + } + } + + /** How many notes have a reminder still ahead, for asking about exact alarms. */ + @WorkerThread + public int countUpcoming(long now) { + return database.noteDao().countUpcomingRemindersSync(now); + } + + static boolean shouldArm(@NonNull Note note, long now) { + Long time = note.getReminderTime(); + if (time == null) return false; + if (time > now) return true; + if (RepeatRule.parse(note.getReminderRepeat()).isRepeating()) return true; + return now - time <= MISSED_ONE_TIME_GRACE_MS; + } +} diff --git a/app/src/main/java/com/pasich/mynotes/utils/reminder/RepeatRuleFormatter.java b/app/src/main/java/com/pasich/mynotes/utils/reminder/RepeatRuleFormatter.java new file mode 100644 index 00000000..24facdc3 --- /dev/null +++ b/app/src/main/java/com/pasich/mynotes/utils/reminder/RepeatRuleFormatter.java @@ -0,0 +1,64 @@ +package com.pasich.mynotes.utils.reminder; + +import android.content.Context; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.PluralsRes; +import com.pasich.mynotes.R; +import com.pasich.mynotes.data.model.RepeatRule; + +/** Words for a repeat rule, the same everywhere a reminder shows how it repeats. */ +public final class RepeatRuleFormatter { + + private RepeatRuleFormatter() {} + + /** "Daily", "Every hour", "Every 3 hours"; null for a reminder that does not repeat. */ + @Nullable + public static String summary(@NonNull Context ctx, @NonNull RepeatRule rule) { + if (!rule.isRepeating()) return null; + int n = rule.getInterval(); + if (n == 1) { + return ctx.getString( + switch (rule.getUnit()) { + case HOURS -> R.string.reminder_repeat_hourly; + case DAYS -> R.string.reminder_repeat_daily; + case WEEKS -> R.string.reminder_repeat_weekly; + case MONTHS -> R.string.reminder_repeat_monthly; + case YEARS -> R.string.reminder_repeat_yearly; + }); + } + return ctx.getResources().getQuantityString(everyPlural(rule.getUnit()), n, n); + } + + /** The summary of a stored repeat value, or null when it does not repeat. */ + @Nullable + public static String summary(@NonNull Context ctx, @Nullable String storedRepeat) { + return summary(ctx, RepeatRule.parse(storedRepeat)); + } + + /** "3 hours", for the unit choices in the custom repeat dialog. */ + @NonNull + public static String unitCount(@NonNull Context ctx, @NonNull RepeatRule.Unit unit, int n) { + @PluralsRes + int res = + switch (unit) { + case HOURS -> R.plurals.reminder_unit_hours; + case DAYS -> R.plurals.reminder_unit_days; + case WEEKS -> R.plurals.reminder_unit_weeks; + case MONTHS -> R.plurals.reminder_unit_months; + case YEARS -> R.plurals.reminder_unit_years; + }; + return ctx.getResources().getQuantityString(res, n, n); + } + + @PluralsRes + private static int everyPlural(@NonNull RepeatRule.Unit unit) { + return switch (unit) { + case HOURS -> R.plurals.reminder_repeat_every_hours; + case DAYS -> R.plurals.reminder_repeat_every_days; + case WEEKS -> R.plurals.reminder_repeat_every_weeks; + case MONTHS -> R.plurals.reminder_repeat_every_months; + case YEARS -> R.plurals.reminder_repeat_every_years; + }; + } +} diff --git a/app/src/main/java/com/pasich/mynotes/utils/reminder/SnoozeStore.java b/app/src/main/java/com/pasich/mynotes/utils/reminder/SnoozeStore.java new file mode 100644 index 00000000..a54923b8 --- /dev/null +++ b/app/src/main/java/com/pasich/mynotes/utils/reminder/SnoozeStore.java @@ -0,0 +1,75 @@ +package com.pasich.mynotes.utils.reminder; + +import android.content.Context; +import android.content.SharedPreferences; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +/** + * Snoozed note reminders on this device, kept so that a reboot or an app update re-arms them. + * + *

A snooze is a choice made on this phone for a notification shown on this phone, so it stays + * here and is never synced; the note's own schedule is not changed by it. + */ +public final class SnoozeStore { + + private static final String PREFS = "reminder_snoozes"; + + /** One remembered snooze. */ + public static final class Entry { + public final int noteId; + public final long time; + public final int intervalMinutes; + + Entry(int noteId, long time, int intervalMinutes) { + this.noteId = noteId; + this.time = time; + this.intervalMinutes = intervalMinutes; + } + } + + private final SharedPreferences prefs; + + public SnoozeStore(@NonNull Context context) { + prefs = context.getApplicationContext().getSharedPreferences(PREFS, Context.MODE_PRIVATE); + } + + public void put(int noteId, long time, int intervalMinutes) { + prefs.edit().putString(String.valueOf(noteId), time + ":" + intervalMinutes).apply(); + } + + public void remove(int noteId) { + prefs.edit().remove(String.valueOf(noteId)).apply(); + } + + /** The snooze remembered for the note, or null. */ + @Nullable + public Entry get(int noteId) { + for (Entry entry : all()) { + if (entry.noteId == noteId) return entry; + } + return null; + } + + @NonNull + public List all() { + List entries = new ArrayList<>(); + for (Map.Entry e : prefs.getAll().entrySet()) { + if (!(e.getValue() instanceof String)) continue; + try { + String[] parts = ((String) e.getValue()).split(":", 2); + entries.add( + new Entry( + Integer.parseInt(e.getKey()), + Long.parseLong(parts[0]), + parts.length > 1 ? Integer.parseInt(parts[1]) : 0)); + } catch (NumberFormatException ignored) { + // A malformed entry cannot be re-armed; it is dropped at the next remove. + } + } + return entries; + } +} diff --git a/app/src/main/java/com/pasich/mynotes/utils/reminder/TaskReminderManager.java b/app/src/main/java/com/pasich/mynotes/utils/reminder/TaskReminderManager.java index e42b209d..0397d5bf 100644 --- a/app/src/main/java/com/pasich/mynotes/utils/reminder/TaskReminderManager.java +++ b/app/src/main/java/com/pasich/mynotes/utils/reminder/TaskReminderManager.java @@ -4,7 +4,6 @@ import android.app.PendingIntent; import android.content.Context; import android.content.Intent; -import android.os.Build; import com.pasich.mynotes.data.model.Task; import com.pasich.mynotes.ui.receiver.TaskReminderReceiver; import java.util.List; @@ -19,14 +18,11 @@ public class TaskReminderManager { // Offset to avoid collision with note reminder PendingIntent request codes private static final int REQUEST_CODE_OFFSET = 100000; - /** Schedules an exact alarm for the given task's reminder time. */ + /** Schedules an alarm for the given task's reminder time. */ public static void scheduleReminder(Context ctx, Task task) { if (task.getReminderTime() == null) return; - AlarmManager am = (AlarmManager) ctx.getSystemService(Context.ALARM_SERVICE); - if (am == null) return; - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && !am.canScheduleExactAlarms()) return; - am.setExactAndAllowWhileIdle( - AlarmManager.RTC_WAKEUP, task.getReminderTime(), buildPendingIntent(ctx, task)); + // Without "Alarms & reminders" the task is still reminded, possibly a little late. + ReminderManager.arm(ctx, task.getReminderTime(), buildPendingIntent(ctx, task)); } /** Cancels a previously scheduled alarm for the given task ID. */ diff --git a/app/src/main/java/com/pasich/mynotes/utils/search/NoteSearchRanker.java b/app/src/main/java/com/pasich/mynotes/utils/search/NoteSearchRanker.java new file mode 100644 index 00000000..000bad46 --- /dev/null +++ b/app/src/main/java/com/pasich/mynotes/utils/search/NoteSearchRanker.java @@ -0,0 +1,133 @@ +package com.pasich.mynotes.utils.search; + +import com.pasich.mynotes.data.model.Note; +import com.pasich.mynotes.utils.search.SearchHit.MatchKind; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; + +/** + * Ranks notes against a search query. + * + *

Matching is case-insensitive and ignores diacritics and extra whitespace (see {@link + * SearchText}). Results come in buckets, best first: exact title, title starts with the query, the + * query as a whole word in the title, the query anywhere in the title, the tag name, and finally + * the note text. Inside a bucket pinned notes come first, then the newest. + * + *

A one-character query only looks at titles: a single letter is in almost every note's text, so + * those matches would bury the useful ones. The text is searched from {@link + * #MIN_BODY_QUERY_LENGTH} characters. + * + *

Each note is folded once per list, not once per keystroke: the folded forms are kept for as + * long as the same list instance keeps coming back, which is the case while only the query changes. + * Not thread-safe; the presenter calls it from one stream. + */ +public final class NoteSearchRanker { + + /** Shortest query, in folded characters, that also searches tag names and note text. */ + public static final int MIN_BODY_QUERY_LENGTH = 2; + + private static final Comparator ORDER = + Comparator.comparingInt(r -> r.kind.ordinal()) + .thenComparing(r -> !r.note.isPinned()) + .thenComparing(r -> r.note.getDate(), Comparator.reverseOrder()) + .thenComparing(r -> r.note.getId(), Comparator.reverseOrder()); + + private List indexedNotes; + private Folded[] index; + + /** + * Whether {@code rawQuery} is too short to search note text, so an empty result should ask the + * user to keep typing rather than say nothing was found. + */ + public static boolean searchesTitlesOnly(String rawQuery) { + return SearchText.normalize(rawQuery).length() < MIN_BODY_QUERY_LENGTH; + } + + /** + * Returns the notes matching {@code rawQuery}, best first. An empty query matches nothing. A + * non-empty {@code tagFilter} keeps only notes with exactly that tag. + */ + public List rank(List notes, String rawQuery, String tagFilter) { + String query = SearchText.normalize(rawQuery); + if (query.isEmpty() || notes == null || notes.isEmpty()) return Collections.emptyList(); + + boolean titlesOnly = query.length() < MIN_BODY_QUERY_LENGTH; + boolean hasTagFilter = tagFilter != null && !tagFilter.isEmpty(); + Folded[] folded = indexFor(notes); + + List ranked = new ArrayList<>(); + for (int i = 0; i < folded.length; i++) { + Note note = notes.get(i); + if (hasTagFilter && !tagFilter.equals(note.getTag())) continue; + MatchKind kind = classify(folded[i], query, titlesOnly); + if (kind != null) ranked.add(new Ranked(note, kind)); + } + ranked.sort(ORDER); + + List hits = new ArrayList<>(ranked.size()); + for (Ranked r : ranked) hits.add(new SearchHit(r.note, r.kind, query)); + return hits; + } + + private Folded[] indexFor(List notes) { + if (notes != indexedNotes || index == null || index.length != notes.size()) { + Folded[] fresh = new Folded[notes.size()]; + for (int i = 0; i < fresh.length; i++) fresh[i] = new Folded(notes.get(i)); + index = fresh; + indexedNotes = notes; + } + return index; + } + + private static MatchKind classify(Folded note, String query, boolean titlesOnly) { + String title = note.title; + int at = title.indexOf(query); + if (at >= 0) { + if (at == 0 && title.length() == query.length()) return MatchKind.EXACT_TITLE; + if (at == 0) return MatchKind.TITLE_PREFIX; + if (hasWholeWord(title, query, at)) return MatchKind.TITLE_WORD; + return MatchKind.TITLE_CONTAINS; + } + if (titlesOnly) return null; + if (note.tag.contains(query)) return MatchKind.TAG; + if (note.body.contains(query)) return MatchKind.BODY; + return null; + } + + /** Whether any occurrence of {@code query} from {@code from} on stands as whole words. */ + private static boolean hasWholeWord(String text, String query, int from) { + for (int at = from; at >= 0; at = text.indexOf(query, at + 1)) { + int end = at + query.length(); + boolean startsWord = + !SearchText.isWordChar(text, at - 1) || !SearchText.isWordChar(text, at); + boolean endsWord = + !SearchText.isWordChar(text, end) || !SearchText.isWordChar(text, end - 1); + if (startsWord && endsWord) return true; + } + return false; + } + + private static final class Folded { + final String title; + final String tag; + final String body; + + Folded(Note note) { + title = SearchText.normalize(note.getTitle()); + tag = SearchText.normalize(note.getTag()); + body = SearchText.normalize(note.getValue()); + } + } + + private static final class Ranked { + final Note note; + final MatchKind kind; + + Ranked(Note note, MatchKind kind) { + this.note = note; + this.kind = kind; + } + } +} diff --git a/app/src/main/java/com/pasich/mynotes/utils/search/SearchHintFitter.java b/app/src/main/java/com/pasich/mynotes/utils/search/SearchHintFitter.java new file mode 100644 index 00000000..0b01afb2 --- /dev/null +++ b/app/src/main/java/com/pasich/mynotes/utils/search/SearchHintFitter.java @@ -0,0 +1,48 @@ +package com.pasich.mynotes.utils.search; + +import android.widget.TextView; +import androidx.annotation.NonNull; +import androidx.annotation.VisibleForTesting; +import com.google.android.material.search.SearchBar; + +/** + * Keeps the search bar hint readable. The full hint ("Search notes") is long in some languages and + * at large font sizes it would be cut off with an ellipsis; when it does not fit the space the bar + * gives it, the short one ("Search") is shown instead. + */ +public final class SearchHintFitter { + + private SearchHintFitter() {} + + /** Re-checks the hint whenever the bar's text area is laid out again. */ + public static void attach( + @NonNull SearchBar searchBar, @NonNull String fullHint, @NonNull String shortHint) { + TextView text = searchBar.getTextView(); + text.addOnLayoutChangeListener( + (v, left, top, right, bottom, oldLeft, oldTop, oldRight, oldBottom) -> { + float available = + text.getWidth() - text.getPaddingLeft() - text.getPaddingRight(); + if (available <= 0) return; + String hint = + choose( + text.getPaint().measureText(fullHint), + available, + fullHint, + shortHint); + CharSequence current = searchBar.getHint(); + if (current == null || !hint.contentEquals(current)) { + // Changing the hint during layout would be ignored until the next pass. + text.post(() -> searchBar.setHint(hint)); + } + }); + } + + /** + * The full hint when it fits {@code available} pixels, the short one otherwise. Half a pixel of + * slack covers the rounding between a measured string and the width laid out for it. + */ + @VisibleForTesting + static String choose(float fullWidth, float available, String fullHint, String shortHint) { + return fullWidth <= available + 0.5f ? fullHint : shortHint; + } +} diff --git a/app/src/main/java/com/pasich/mynotes/utils/search/SearchHit.java b/app/src/main/java/com/pasich/mynotes/utils/search/SearchHit.java new file mode 100644 index 00000000..6276279c --- /dev/null +++ b/app/src/main/java/com/pasich/mynotes/utils/search/SearchHit.java @@ -0,0 +1,64 @@ +package com.pasich.mynotes.utils.search; + +import com.pasich.mynotes.data.model.Note; +import java.util.Objects; + +/** One search result: the note, why it matched, and the folded query that found it. */ +public final class SearchHit { + + private final Note note; + private final MatchKind kind; + private final String query; + + public SearchHit(Note note, MatchKind kind, String query) { + this.note = note; + this.kind = kind; + this.query = query; + } + + public Note note() { + return note; + } + + public MatchKind kind() { + return kind; + } + + /** The query as {@link SearchText#normalize} folded it, ready for {@link SearchText#locate}. */ + public String query() { + return query; + } + + /** Whether this hit draws the same card as {@code other}: same note row, same highlight. */ + public boolean sameContentAs(SearchHit other) { + return note == other.note && kind == other.kind && query.equals(other.query); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof SearchHit)) return false; + return sameContentAs((SearchHit) o); + } + + @Override + public int hashCode() { + return Objects.hash(System.identityHashCode(note), kind, query); + } + + /** Ranking buckets, best first. */ + public enum MatchKind { + /** The whole title is the query. */ + EXACT_TITLE, + /** The title starts with the query. */ + TITLE_PREFIX, + /** The query is a whole word (or words) inside the title. */ + TITLE_WORD, + /** The query appears anywhere in the title. */ + TITLE_CONTAINS, + /** The note's tag name contains the query. */ + TAG, + /** Only the note's text contains the query. */ + BODY + } +} diff --git a/app/src/main/java/com/pasich/mynotes/utils/search/SearchText.java b/app/src/main/java/com/pasich/mynotes/utils/search/SearchText.java new file mode 100644 index 00000000..cee6e27f --- /dev/null +++ b/app/src/main/java/com/pasich/mynotes/utils/search/SearchText.java @@ -0,0 +1,138 @@ +package com.pasich.mynotes.utils.search; + +import java.text.Normalizer; +import java.util.Arrays; + +/** + * Folds text into the form search compares: compatibility-decomposed (NFKD), diacritics removed, + * lower-cased per code point with the locale-independent mapping, and every run of whitespace + * collapsed into a single space with none at either end. + * + *

So "Caf\u00e9", "CAFE" and "cafe" all fold to {@code cafe}, and " file notes " to {@code file + * notes}. Plain Java, no Android types, so it is unit-tested directly. + */ +public final class SearchText { + + private SearchText() {} + + /** Folds {@code text} for comparison. {@code null} folds to the empty string. */ + public static String normalize(String text) { + if (text == null || text.isEmpty()) return ""; + String decomposed = isAscii(text) ? text : Normalizer.normalize(text, Normalizer.Form.NFKD); + StringBuilder out = new StringBuilder(decomposed.length()); + boolean pendingSpace = false; + for (int i = 0; i < decomposed.length(); ) { + int cp = decomposed.codePointAt(i); + i += Character.charCount(cp); + pendingSpace = append(out, cp, pendingSpace, null, 0, 0); + } + return out.toString(); + } + + /** + * Finds the first place where already-normalized {@code query} occurs in {@code original} once + * that is folded, and returns it as a {@code [start, end)} range of {@code original}'s own + * indices, or {@code null} when it does not occur. Used to highlight a match in the text the + * user actually sees, where an accent or a ligature makes the two lengths differ. + */ + public static int[] locate(String original, String query) { + if (original == null || original.isEmpty() || query == null || query.isEmpty()) { + return null; + } + StringBuilder out = new StringBuilder(original.length()); + IndexMap map = new IndexMap(original.length()); + boolean pendingSpace = false; + for (int i = 0; i < original.length(); ) { + int cp = original.codePointAt(i); + int next = i + Character.charCount(cp); + if (cp < 0x80) { + pendingSpace = append(out, cp, pendingSpace, map, i, next); + } else { + String piece = + Normalizer.normalize(original.substring(i, next), Normalizer.Form.NFKD); + for (int j = 0; j < piece.length(); ) { + int pcp = piece.codePointAt(j); + j += Character.charCount(pcp); + pendingSpace = append(out, pcp, pendingSpace, map, i, next); + } + } + i = next; + } + int at = out.indexOf(query); + if (at < 0) return null; + return new int[] {map.start[at], map.end[at + query.length() - 1]}; + } + + /** Whether the character at a folded index is a letter or digit (a word, not a boundary). */ + static boolean isWordChar(String folded, int index) { + if (index < 0 || index >= folded.length()) return false; + return Character.isLetterOrDigit(folded.codePointAt(index)); + } + + /** + * Appends one decomposed code point to {@code out} and returns whether a space is still + * pending. The space is written only when a visible character follows it, which collapses runs + * and trims both ends in a single pass. + */ + private static boolean append( + StringBuilder out, int cp, boolean pendingSpace, IndexMap map, int from, int to) { + if (isMark(cp)) return pendingSpace; + if (isSpace(cp)) return pendingSpace || out.length() > 0; + if (pendingSpace) { + out.append(' '); + if (map != null) map.add(from, to); + } + int folded = fold(cp); + out.appendCodePoint(folded); + if (map != null) { + for (int k = Character.charCount(folded); k > 0; k--) map.add(from, to); + } + return false; + } + + private static int fold(int cp) { + int lower = Character.toLowerCase(cp); + // Final sigma is only a spelling of sigma; fold it so a word ending in it still matches. + return lower == '\u03C2' ? '\u03C3' : lower; + } + + private static boolean isMark(int cp) { + int type = Character.getType(cp); + return type == Character.NON_SPACING_MARK + || type == Character.COMBINING_SPACING_MARK + || type == Character.ENCLOSING_MARK; + } + + private static boolean isSpace(int cp) { + return Character.isWhitespace(cp) || Character.isSpaceChar(cp); + } + + private static boolean isAscii(String s) { + for (int i = 0; i < s.length(); i++) { + if (s.charAt(i) >= 0x80) return false; + } + return true; + } + + /** Original {@code [start, end)} range behind each folded char. */ + private static final class IndexMap { + int[] start; + int[] end; + int size; + + IndexMap(int capacity) { + start = new int[Math.max(capacity, 4)]; + end = new int[start.length]; + } + + void add(int from, int to) { + if (size == start.length) { + start = Arrays.copyOf(start, size * 2); + end = Arrays.copyOf(end, size * 2); + } + start[size] = from; + end[size] = to; + size++; + } + } +} diff --git a/app/src/main/java/com/pasich/mynotes/utils/tool/FormatListTool.java b/app/src/main/java/com/pasich/mynotes/utils/tool/FormatListTool.java index 352060eb..b0308534 100644 --- a/app/src/main/java/com/pasich/mynotes/utils/tool/FormatListTool.java +++ b/app/src/main/java/com/pasich/mynotes/utils/tool/FormatListTool.java @@ -1,11 +1,24 @@ package com.pasich.mynotes.utils.tool; import android.view.MenuItem; +import androidx.annotation.DrawableRes; +import androidx.annotation.StringRes; import com.pasich.mynotes.R; import com.pasich.mynotes.cache.AppPreferencesCache; import javax.inject.Inject; +/** + * Owns the notes layout preference (one column or a two-column grid) and keeps the "View options" + * toolbar action showing the layout that is currently on screen. + */ public class FormatListTool { + + /** One note per row. */ + public static final int FORMAT_LIST = 1; + + /** Two columns of cards. */ + public static final int FORMAT_GRID = 2; + private final AppPreferencesCache cache; @Inject @@ -14,38 +27,47 @@ public FormatListTool(AppPreferencesCache cache) { this.cache.initialize(); } - public void init(MenuItem button) { - button.setIcon(getParamIco(getParamFormatValue())); + /** The saved layout, {@link #FORMAT_LIST} or {@link #FORMAT_GRID}. */ + public int getFormat() { + return cache.getFormatPref() == FORMAT_GRID ? FORMAT_GRID : FORMAT_LIST; } - private int getParamFormatValue() { - return cache.getFormatPref(); - } - - /** The switch itself, which switches the operating mode depending on the selected parameter */ - public void formatNote(MenuItem menuItem) { - switch (getParamFormatValue()) { - case 1 -> { - cache.setFormatPref(2); - menuItem.setIcon(getParamIco(2)); - } - case 2 -> { - cache.setFormatPref(1); - menuItem.setIcon(getParamIco(1)); - } - } + /** Shows the saved layout on the toolbar action. Call once the menu exists. */ + public void init(MenuItem button) { + bind(button, getFormat()); } /** - * Method to return int drawable + * Saves {@code format} and updates the toolbar action. * - * @param param - The option chosen by the user - * @return - int drawable + * @return true when the layout actually changed */ - private int getParamIco(int param) { - if (param == 2) { - return R.drawable.ic_edit_format_list; - } - return R.drawable.ic_edit_format_tiles; + public boolean setFormat(int format, MenuItem button) { + int normalized = format == FORMAT_GRID ? FORMAT_GRID : FORMAT_LIST; + boolean changed = normalized != getFormat(); + if (changed) cache.setFormatPref(normalized); + bind(button, normalized); + return changed; + } + + private static void bind(MenuItem button, int format) { + if (button == null) return; + button.setIcon(iconFor(format)); + } + + /** The icon of the layout itself: a list for {@link #FORMAT_LIST}, tiles for the grid. */ + @DrawableRes + public static int iconFor(int format) { + return format == FORMAT_GRID + ? R.drawable.ic_edit_format_tiles + : R.drawable.ic_edit_format_list; + } + + /** What TalkBack reads for the toolbar action, including the current layout. */ + @StringRes + public static int contentDescriptionFor(int format) { + return format == FORMAT_GRID + ? R.string.view_options_cd_grid + : R.string.view_options_cd_list; } } diff --git a/app/src/main/res/color/editor_keyboard_bar_icon.xml b/app/src/main/res/color/editor_keyboard_bar_icon.xml new file mode 100644 index 00000000..0b2cbc15 --- /dev/null +++ b/app/src/main/res/color/editor_keyboard_bar_icon.xml @@ -0,0 +1,7 @@ + + + + + + diff --git a/app/src/main/res/drawable/bg_meet_encly_card.xml b/app/src/main/res/drawable/bg_meet_encly_card.xml new file mode 100644 index 00000000..827c912d --- /dev/null +++ b/app/src/main/res/drawable/bg_meet_encly_card.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/app/src/main/res/drawable/bg_meet_encly_circle.xml b/app/src/main/res/drawable/bg_meet_encly_circle.xml new file mode 100644 index 00000000..efebdafa --- /dev/null +++ b/app/src/main/res/drawable/bg_meet_encly_circle.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/app/src/main/res/drawable/bg_meet_encly_pill.xml b/app/src/main/res/drawable/bg_meet_encly_pill.xml new file mode 100644 index 00000000..5fe7cb42 --- /dev/null +++ b/app/src/main/res/drawable/bg_meet_encly_pill.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/app/src/main/res/drawable/bg_meet_encly_tonal.xml b/app/src/main/res/drawable/bg_meet_encly_tonal.xml new file mode 100644 index 00000000..bdc00b97 --- /dev/null +++ b/app/src/main/res/drawable/bg_meet_encly_tonal.xml @@ -0,0 +1,7 @@ + + + + + + diff --git a/app/src/main/res/drawable/bg_version_reason.xml b/app/src/main/res/drawable/bg_version_reason.xml new file mode 100644 index 00000000..8c43835d --- /dev/null +++ b/app/src/main/res/drawable/bg_version_reason.xml @@ -0,0 +1,8 @@ + + + + + + + diff --git a/app/src/main/res/drawable/ic_arrow_forward.xml b/app/src/main/res/drawable/ic_arrow_forward.xml index feca0bc3..6af46568 100644 --- a/app/src/main/res/drawable/ic_arrow_forward.xml +++ b/app/src/main/res/drawable/ic_arrow_forward.xml @@ -1,6 +1,7 @@ + + + diff --git a/app/src/main/res/drawable/ic_code.xml b/app/src/main/res/drawable/ic_code.xml new file mode 100644 index 00000000..04e0fc67 --- /dev/null +++ b/app/src/main/res/drawable/ic_code.xml @@ -0,0 +1,10 @@ + + + + diff --git a/app/src/main/res/drawable/ic_encly_logo.xml b/app/src/main/res/drawable/ic_encly_logo.xml new file mode 100644 index 00000000..51b2d610 --- /dev/null +++ b/app/src/main/res/drawable/ic_encly_logo.xml @@ -0,0 +1,23 @@ + + + + + + + + diff --git a/app/src/main/res/drawable/ic_image.xml b/app/src/main/res/drawable/ic_image.xml new file mode 100644 index 00000000..cd6a4f0b --- /dev/null +++ b/app/src/main/res/drawable/ic_image.xml @@ -0,0 +1,10 @@ + + + + diff --git a/app/src/main/res/drawable/ic_keyboard_hide.xml b/app/src/main/res/drawable/ic_keyboard_hide.xml new file mode 100644 index 00000000..3796e86e --- /dev/null +++ b/app/src/main/res/drawable/ic_keyboard_hide.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_lock.xml b/app/src/main/res/drawable/ic_lock.xml new file mode 100644 index 00000000..6b9ce52c --- /dev/null +++ b/app/src/main/res/drawable/ic_lock.xml @@ -0,0 +1,10 @@ + + + + diff --git a/app/src/main/res/drawable/ic_redo.xml b/app/src/main/res/drawable/ic_redo.xml new file mode 100644 index 00000000..3720b7c8 --- /dev/null +++ b/app/src/main/res/drawable/ic_redo.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable/ic_undo.xml b/app/src/main/res/drawable/ic_undo.xml new file mode 100644 index 00000000..82a4c908 --- /dev/null +++ b/app/src/main/res/drawable/ic_undo.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable/ic_vertical_align_top.xml b/app/src/main/res/drawable/ic_vertical_align_top.xml new file mode 100644 index 00000000..7ffaabc5 --- /dev/null +++ b/app/src/main/res/drawable/ic_vertical_align_top.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/layout/action_panel.xml b/app/src/main/res/layout/action_panel.xml index e8a651f2..d702f5ae 100644 --- a/app/src/main/res/layout/action_panel.xml +++ b/app/src/main/res/layout/action_panel.xml @@ -39,7 +39,7 @@ android:id="@+id/action_tag_change" style="@style/Widget.IconButton" app:icon="@drawable/ic_tag" - android:contentDescription="@string/tag_options" /> + android:contentDescription="@string/selection_change_tag" /> - - + android:paddingBottom="80dp" /> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/activity_note.xml b/app/src/main/res/layout/activity_note.xml index d9edaa0e..95afa3ac 100644 --- a/app/src/main/res/layout/activity_note.xml +++ b/app/src/main/res/layout/activity_note.xml @@ -183,6 +183,18 @@ android:layout_height="wrap_content" android:orientation="vertical"> + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_note_extended_editor.xml b/app/src/main/res/layout/activity_note_extended_editor.xml index 8cef1ade..4c52ebc8 100644 --- a/app/src/main/res/layout/activity_note_extended_editor.xml +++ b/app/src/main/res/layout/activity_note_extended_editor.xml @@ -1,6 +1,7 @@ + xmlns:app="http://schemas.android.com/apk/res-auto" + xmlns:tools="http://schemas.android.com/tools"> @@ -75,6 +76,23 @@ + + + + app:layout_constraintTop_toBottomOf="@id/reminderChip" /> + + + diff --git a/app/src/main/res/layout/activity_note_history.xml b/app/src/main/res/layout/activity_note_history.xml new file mode 100644 index 00000000..22a84cf5 --- /dev/null +++ b/app/src/main/res/layout/activity_note_history.xml @@ -0,0 +1,84 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/dialog_meet_encly.xml b/app/src/main/res/layout/dialog_meet_encly.xml new file mode 100644 index 00000000..31cc0cf3 --- /dev/null +++ b/app/src/main/res/layout/dialog_meet_encly.xml @@ -0,0 +1,137 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/dialog_more_note.xml b/app/src/main/res/layout/dialog_more_note.xml index c56ea1e0..21fe6b1f 100644 --- a/app/src/main/res/layout/dialog_more_note.xml +++ b/app/src/main/res/layout/dialog_more_note.xml @@ -39,23 +39,22 @@ android:visibility="@{activityNote ? View.VISIBLE : View.GONE}" /> - + android:paddingHorizontal="4dp" + android:paddingVertical="12dp"> @@ -78,10 +80,10 @@ android:id="@+id/quickCopy" android:layout_width="0dp" android:layout_height="wrap_content" - android:layout_weight="1" android:gravity="center" android:orientation="vertical" - android:padding="12dp"> + android:paddingHorizontal="4dp" + android:paddingVertical="12dp"> @@ -104,10 +109,10 @@ android:id="@+id/quickTranslate" android:layout_width="0dp" android:layout_height="wrap_content" - android:layout_weight="1" android:gravity="center" android:orientation="vertical" - android:padding="12dp" + android:paddingHorizontal="4dp" + android:paddingVertical="12dp" android:visibility="@{valuesText ? View.VISIBLE : View.GONE}"> @@ -131,10 +139,10 @@ android:id="@+id/quickPin" android:layout_width="0dp" android:layout_height="wrap_content" - android:layout_weight="1" android:gravity="center" android:orientation="vertical" - android:padding="12dp"> + android:paddingHorizontal="4dp" + android:paddingVertical="12dp"> - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/dialog_reminder_picker.xml b/app/src/main/res/layout/dialog_reminder_picker.xml index 6620abdc..af60e8c4 100644 --- a/app/src/main/res/layout/dialog_reminder_picker.xml +++ b/app/src/main/res/layout/dialog_reminder_picker.xml @@ -59,6 +59,8 @@ android:layout_height="wrap_content" android:layout_weight="1" app:cornerRadius="8dp" + android:maxLines="2" + android:paddingHorizontal="12dp" android:text="@string/reminder_today_evening" /> @@ -70,6 +72,8 @@ android:layout_height="wrap_content" android:layout_weight="1" app:cornerRadius="8dp" + android:maxLines="2" + android:paddingHorizontal="12dp" android:text="@string/reminder_tomorrow_morning" /> @@ -133,6 +137,17 @@ + + + + + + diff --git a/app/src/main/res/layout/dialog_repeat_custom.xml b/app/src/main/res/layout/dialog_repeat_custom.xml new file mode 100644 index 00000000..8d6e30c8 --- /dev/null +++ b/app/src/main/res/layout/dialog_repeat_custom.xml @@ -0,0 +1,77 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/dialog_view_options.xml b/app/src/main/res/layout/dialog_view_options.xml new file mode 100644 index 00000000..74c3596c --- /dev/null +++ b/app/src/main/res/layout/dialog_view_options.xml @@ -0,0 +1,153 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/fragment_interaction_settings.xml b/app/src/main/res/layout/fragment_interaction_settings.xml index c69396a1..83f3675d 100644 --- a/app/src/main/res/layout/fragment_interaction_settings.xml +++ b/app/src/main/res/layout/fragment_interaction_settings.xml @@ -174,6 +174,194 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/item_choice_dialog.xml b/app/src/main/res/layout/item_choice_dialog.xml new file mode 100644 index 00000000..2ff7d033 --- /dev/null +++ b/app/src/main/res/layout/item_choice_dialog.xml @@ -0,0 +1,60 @@ + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/item_note.xml b/app/src/main/res/layout/item_note.xml index de4438b2..428d928f 100644 --- a/app/src/main/res/layout/item_note.xml +++ b/app/src/main/res/layout/item_note.xml @@ -60,6 +60,19 @@ android:layout_height="10dp" android:visibility="@{(Note.tag.length() > 0 || Note.isAttachments() || Note.hasReminder() || Note.pinned) ? View.VISIBLE : View.GONE}" /> + + + + + android:layout_weight="1" + android:visibility="@{Note.hasReminder() ? View.GONE : View.VISIBLE}" /> + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/item_note_version_header.xml b/app/src/main/res/layout/item_note_version_header.xml new file mode 100644 index 00000000..1c263ba2 --- /dev/null +++ b/app/src/main/res/layout/item_note_version_header.xml @@ -0,0 +1,29 @@ + + + + + + + + diff --git a/app/src/main/res/layout/item_result.xml b/app/src/main/res/layout/item_result.xml index 764b2583..e504318d 100644 --- a/app/src/main/res/layout/item_result.xml +++ b/app/src/main/res/layout/item_result.xml @@ -29,14 +29,12 @@ - + @@ -127,7 +130,8 @@ android:layout_width="24dp" android:layout_height="24dp" android:layout_marginEnd="16dp" - android:contentDescription="@string/settings" + android:contentDescription="@null" + android:importantForAccessibility="no" android:src="@drawable/ic_interface_set" app:tint="?attr/colorPrimary" /> @@ -156,7 +160,8 @@ android:layout_width="24dp" android:layout_height="24dp" android:layout_marginEnd="16dp" - android:contentDescription="@string/your_data" + android:contentDescription="@null" + android:importantForAccessibility="no" android:src="@drawable/ic_backup_restore" app:tint="?attr/colorPrimary" /> @@ -185,7 +190,8 @@ android:layout_width="24dp" android:layout_height="24dp" android:layout_gravity="center_vertical" - android:contentDescription="@string/aboutApps" + android:contentDescription="@null" + android:importantForAccessibility="no" android:src="@drawable/ic_about" app:tint="?attr/colorPrimary" /> @@ -242,7 +248,8 @@ android:layout_width="24dp" android:layout_height="24dp" android:layout_marginEnd="16dp" - android:contentDescription="@string/whatNewVersion" + android:contentDescription="@null" + android:importantForAccessibility="no" android:src="@drawable/ic_new_w" app:tint="?attr/colorPrimary" /> @@ -254,6 +261,53 @@ android:textSize="16sp" /> + + + + + + + + + + + + diff --git a/app/src/main/res/layout/sheet_note_version.xml b/app/src/main/res/layout/sheet_note_version.xml new file mode 100644 index 00000000..f9474677 --- /dev/null +++ b/app/src/main/res/layout/sheet_note_version.xml @@ -0,0 +1,160 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/view_editor_keyboard_bar.xml b/app/src/main/res/layout/view_editor_keyboard_bar.xml new file mode 100644 index 00000000..47d4bd4c --- /dev/null +++ b/app/src/main/res/layout/view_editor_keyboard_bar.xml @@ -0,0 +1,60 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/view_note_editor.xml b/app/src/main/res/layout/view_note_editor.xml index 64f95eb2..1b2dde6c 100644 --- a/app/src/main/res/layout/view_note_editor.xml +++ b/app/src/main/res/layout/view_note_editor.xml @@ -9,12 +9,14 @@ android:layout_gravity="center" android:indeterminateTint="?attr/colorPrimary" /> - + diff --git a/app/src/main/res/menu/menu_activity_toolbar_note.xml b/app/src/main/res/menu/menu_activity_toolbar_note.xml index f9c3d846..2f92c715 100644 --- a/app/src/main/res/menu/menu_activity_toolbar_note.xml +++ b/app/src/main/res/menu/menu_activity_toolbar_note.xml @@ -1,26 +1,23 @@ +

+ xmlns:app="http://schemas.android.com/apk/res-auto" + xmlns:tools="http://schemas.android.com/tools" + tools:ignore="AlwaysShowAction"> - - + app:showAsAction="always" /> + app:showAsAction="always" /> - \ No newline at end of file + diff --git a/app/src/main/res/menu/menu_activity_toolbar_note_extendes.xml b/app/src/main/res/menu/menu_activity_toolbar_note_extendes.xml index 58cd0ba1..f2d21be7 100644 --- a/app/src/main/res/menu/menu_activity_toolbar_note_extendes.xml +++ b/app/src/main/res/menu/menu_activity_toolbar_note_extendes.xml @@ -1,34 +1,30 @@ - + + + app:showAsAction="always" /> + - - + app:showAsAction="always" /> + app:showAsAction="always" /> - \ No newline at end of file + diff --git a/app/src/main/res/menu/menu_search_notes.xml b/app/src/main/res/menu/menu_search_notes.xml index e4e39823..4dd38f7a 100644 --- a/app/src/main/res/menu/menu_search_notes.xml +++ b/app/src/main/res/menu/menu_search_notes.xml @@ -1,15 +1,11 @@ + xmlns:app="http://schemas.android.com/apk/res-auto"> + - - \ No newline at end of file + android:title="@string/view_options" + app:showAsAction="always" /> + diff --git a/app/src/main/res/values-be/strings.xml b/app/src/main/res/values-be/strings.xml index c049c9d6..d713ea46 100644 --- a/app/src/main/res/values-be/strings.xml +++ b/app/src/main/res/values-be/strings.xml @@ -14,9 +14,8 @@ Адкрыць кантакты распрацоўніка Гэты кантэнт не падтрымліваецца. Адсутнічае прыкладанне Play Market - Фарматаваць нататкі - Пошук у нататках + Пошук у нататках Закрыць Дадаць пазнаку Выдаліць @@ -102,7 +101,6 @@ Памылка: Рэзервовая копія пашкоджана Памылка сервера, або дрэнная якасць інтэрнэту - Сартаваць нататкі Паважаны карыстальнік @@ -172,9 +170,9 @@ Пошук Выкарыстоўвайце панэль пошуку для хуткага знаходжання вашых нататак па назве або змесце Сартаванне - Сартуйце вашы нататкі па даце апошняй змены або назве ў ўзыходным ці спадным парадку + Націсніце кнопку параметраў прагляду ў радку пошуку, каб паказваць нядаўна змененыя нататкі першымі або апошнімі, або выберыце «Свой парадак» і расстаўце нататкі самі, перацягваючы іх. Фармат адлюстравання - Пераключайцеся паміж спісам і сеткай для адлюстравання вашых нататак у зручным для вас макеце + У тых жа параметрах прагляду пераключайцеся паміж спісам і сеткай. Жэсты свайп @@ -224,6 +222,8 @@ Тэгі Націсніце гэтую кнопку, каб стварыць тэг. Налады тэга + Змяніць тэг + Рэдагаваць нататку Стварыць тэг Нататкі з гэтым тэгам адлюстроўваюцца ў агульным спісе. Нататкі з гэтым тэгам схаваныя ў агульным спісе. @@ -368,6 +368,7 @@ Нагадванне Заўтра %1$s Абярыце будучы час + Абраны час, %1$s, ужо мінуў. Абярыце пазнейшы час. Новая нататка Усе Працягвайце ўводзіць… @@ -449,9 +450,9 @@ Задайце нагадванне для любога задання, націснуўшы значок званочка. У прызначаны час прыйдзе апавяшчэнне. Націсніце на яго, каб адразу адкрыць заданне. Нагадванні Нагадванні пра нататкі - Адкрыйце любую нататку і націсніце значок званочка, каб задаць нагадванне. Выберыце час хуткім варыянтам або праз выбар даты і часу. У прызначаны час з’явіцца апавяшчэнне. + Адкрыйце нататку, націсніце «Больш» і выберыце «Нагадванне». Выберыце час хуткім варыянтам або праз выбар даты і часу. Нагадванне з’явіцца ўверсе нататкі, націсніце на яго, каб змяніць. У прызначаны час з’явіцца апавяшчэнне. Паўтарэнне - Пры наладзе нагадвання для нататкі ўключыце пераключальнік «Паўтор», каб выбраць цыкл: штодня, штотыдня або штомесяц. Пасля кожнага апавяшчэння нагадванне пераносіцца аўтаматычна. + Пры наладзе нагадвання для нататкі выберыце, як яно паўтараецца: штодня, штотыдзень, штомесяц або «Свой…» — кожныя некалькі гадзін, дзён, тыдняў, месяцаў ці гадоў. Кожнае паўтарэнне адлічваецца ад выбранага часу, таму нагадванне на 31-е чысло ў карацейшых месяцах спрацуе ў апошні дзень, а потым зноў 31-га. Паўторныя нагадванні захоўваюцца і пасля перазапуску тэлефона. Інтэрвальныя апавяшчэнні Для паўтаральных нагадванняў можна ўключыць паўторнае апавяшчэнне кожныя 5, 10, 15, 30 або 60 хвілін. Апавяшчэнне будзе паўтарацца з выбраным інтэрвалам да наступнага спрацоўвання. Гук і гучнасць апавяшчэнняў @@ -476,4 +477,195 @@ Пасля пацвярджэння першай сінхранізацыі можна ўключыць фонавую. Яна запускаецца прыблізна кожныя 6 гадзін пры наяўнасці інтэрнэту, і яе можна выключыць у любы час. Вырашэнне канфліктаў Калі лакальная версія і версія на Drive адрозніваюцца, для кожнага канфлікту выберыце, якую захаваць: лакальную або з Google Drive. + + + Знаёмцеся: Encly + Пераемнік «Мае Нататкі» + Ад аўтара «Мае Нататкі» + Вашы нататкі — зашыфраваныя і толькі на вашым тэлефоне. Без акаўнта і без інтэрнэту. + Заўсёды зашыфравана + Кожная нататка захоўваецца ў зашыфраванай базе даных, рэзервовыя копіі таксама зашыфраваныя. + Афлайн і без акаўнта + Encly не мае дазволу на інтэрнэт, таму нататкі ніколі не пакідаюць тэлефон. + Блакіроўка PIN-кодам і адбіткам + Адкрыць нататкі можна толькі вашым PIN-кодам або адбіткам пальца. + Бясплатна і з адкрытым кодам + Код можа праверыць кожны. Ёсць у Google Play, F-Droid і на GitHub. + Перанос капіюе нататкі ў Encly. З «Мае Нататкі» нічога не выдаляецца, а Encly нічога не адпраўляе назад. + Пераедзе ў Encly + Нататкі, разам са сметніцай + Задачы + Тэгі + Спісы задач + У Encly стануць тэгамі + Застанецца ў «Мае Нататкі» + Укладанні і выявы + Encly не захоўвае файлы + Напаміны + У Encly няма напамінаў + Замацаванне нататак + Самі нататкі пераедуць, але незамацаванымі + Нічога — усё пераедзе ў Encly. + Атрымаць Encly + Абнавіць Encly + Перанесці мае нататкі ў Encly + Рыхтуем нататкі… + Адкрыецца Encly і забярэ ўсё за адзін раз. Гэта зойме толькі імгненне. + Бясплатна ў Google Play, F-Droid і на GitHub. Пасля ўсталявання вярніцеся сюды. + Гэтая версія Encly пакуль не ўмее прымаць нататкі. Абнавіце яе і вярніцеся сюды. + Усталяваная праграма Encly — не афіцыйная зборка, таму нататкі ёй не перадаюцца. Усталюйце Encly з Google Play, F-Droid або GitHub. + Нататкі ўжо ў Encly + Нататкі + Прапушчана + Укладанняў і выяў засталося ў «Мае Нататкі»: %1$d. + Перанос не завяршыўся + Перанос скасаваны. Нічога не змянілася. + Encly прымае нататкі толькі ад афіцыйнай праграмы «Мае Нататкі». Усталюйце «Мае Нататкі» з Google Play, F-Droid або GitHub і паспрабуйце зноў. + Гэтая версія Encly не можа прачытаць даныя з гэтай версіі «Мае Нататкі». Абнавіце Encly і паспрабуйце зноў. + Encly не змог прачытаць даныя. Нічога не імпартавана. + Даных занадта шмат, каб перанесці іх за адзін раз. Нічога не імпартавана. + Encly не змог завяршыць імпарт. З «Мае Нататкі» нічога не выдалена. + Не ўдалося падрыхтаваць нататкі для Encly. + «Мае Нататкі» па-ранейшаму захоўваюць уласную копію. Калі яна больш не патрэбна, яе можна ачысціць — спачатку мы папросім пацвярджэнне. + Ачысціць «Мае Нататкі» + Ачысціць «Мае Нататкі»? + Гэта назаўсёды выдаліць з «Мае Нататкі» ўсе нататкі (разам з сметніцай), задачы, тэгі, спісы задач і файлы ўкладанняў. Калі ўключана сінхранізацыя з Google Дыскам, яны будуць выдалены і з сінхранізаванай копіі. Укладанні не пераносіліся ў Encly. Гэта дзеянне нельга адмяніць. + Выдаліць усё + «Мае Нататкі» ачышчаны. + Не ўдалося ачысціць «Мае Нататкі». + У «Мае Нататкі» з\'явіўся пераемнік — Encly: нататкі, якія заўсёды зашыфраваныя і працуюць афлайн. Перанясіце ўсё адным дотыкам. + Як перанесці + Пазней + Пошук + Параметры прагляду + Параметры прагляду. Нататкі паказаны спісам + Параметры прагляду. Нататкі паказаны сеткай + Парадак + Спачатку нядаўна змененыя + Нядаўна змененыя ў канцы + Выгляд + Спіс + Сетка + Рэжым чытання + Вярнуцца да рэдагавання + Нагадванне: %1$s. Змяніць нагадванне + Рэжым адкрыцця нататак + Новыя нататкі заўсёды адкрываюцца гатовымі да набору. + Аўтаматычна + Рэжым чытання ў простым рэдактары, рэжым рэдагавання ў пашыраным + Чытанне + Нічога не зменіцца выпадкова. Націсніце «Рэдагаваць», каб пачаць друкаваць + Рэдагаванне + Курсор на месцы, клавіятура адкрываецца + Адкрываць нататкі + З месца, дзе вы спыніліся + На радку, які вы чыталі, і з курсорам там, дзе вы скончылі рэдагаваць + З пачатку + Заўсёды з першага радка + Падвойны дотык для рэдагавання + У рэжыме чытання двойчы дакраніцеся да тэксту, каб пачаць рэдагаваць менавіта там, дзе дакрануліся. + Выбрана + + + Свой… + Сваё паўтарэнне + Паўтараць кожныя + Увядзіце лік ад 1 да 999 + Гатова + Сваё паўтарэнне: %1$s. Змяніць + Штогадзіну + Штогод + Нагадванні могуць прыходзіць са спазненнем. Дазвольце «Будзільнікі і нагадванні», каб яны прыходзілі своечасова. + Дазволіць + + Кожную %d гадзіну + Кожныя %d гадзіны + Кожныя %d гадзін + Кожныя %d гадзіны + + + Кожны %d дзень + Кожныя %d дні + Кожныя %d дзён + Кожныя %d дня + + + Кожны %d тыдзень + Кожныя %d тыдні + Кожныя %d тыдняў + Кожныя %d тыдня + + + Кожны %d месяц + Кожныя %d месяцы + Кожныя %d месяцаў + Кожныя %d месяца + + + Кожны %d год + Кожныя %d гады + Кожныя %d гадоў + Кожныя %d года + + + %d гадзіна + %d гадзіны + %d гадзін + %d гадзіны + + + %d дзень + %d дні + %d дзён + %d дня + + + %d тыдзень + %d тыдні + %d тыдняў + %d тыдня + + + %d месяц + %d месяцы + %d месяцаў + %d месяца + + + %d год + %d гады + %d гадоў + %d года + + Гісторыя версій + Папярэднія версіі гэтай нататкі захоўваюцца толькі на гэтай прыладзе, да %1$d. Яны не сінхранізуюцца і не трапляюць у рэзервовыя копіі. + Папярэдніх версій пакуль няма + Калі нататка мяняецца, яе папярэдні тэкст захоўваецца тут, каб вы маглі яго вярнуць. + Аўтазахаванне + Перад сінхранізацыяй + Перад аднаўленнем + Перад канфліктам + Толькі што, %1$s + Гэтая версія + Бягучая нататка + У гэтай версіі той самы тэкст, што і ў бягучай нататкі. + Аднавіць + Аднавіць гэтую версію? + Бягучы тэкст нататкі будзе заменены. Ён застанецца ў гісторыі версій, таму вы зможаце да яго вярнуцца. + Укладанні, якіх ужо няма ў нататцы, не вернуцца з гэтай версіяй. + Версія адноўлена + Не ўдалося аднавіць гэтую версію + Пустая нататка + Свой парадак + Націсніце і ўтрымлівайце нататку, а потым перацягніце яе на новае месца. Замацаваныя нататкі застаюцца над астатнімі. + Перамясціць вышэй + Перамясціць ніжэй + Ужо ў пачатку + Ужо ў канцы + Перамешчана на пазіцыю %1$d з %2$d + Адрабіць + Паўтарыць + Схаваць клавіятуру + Каб мяняць парадак перацягваннем, уключыце «Свой парадак» + Уключыць diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 931eaf09..ef28e731 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -14,8 +14,7 @@ Entwicklerkontakte öffnen Dieser Inhalt wird nicht unterstützt. Play Market fehlt - Notizen formatieren - In Notizen suchen + In Notizen suchen Schließen Tag hinzufügen Löschen @@ -94,7 +93,6 @@ Datenwiederherstellung erfolgreich abgeschlossen Fehler: Sicherung beschädigt Serverfehler oder schlechte Internetqualität - Notizen sortieren Lieber Benutzer @@ -164,9 +162,9 @@ Suchen Verwenden Sie die Suchleiste, um Ihre Notizen schnell nach Titel oder Inhalt zu finden Sortierung - Sortieren Sie Ihre Notizen nach dem letzten Änderungsdatum oder Namen in aufsteigender oder absteigender Reihenfolge + Tippen Sie in der Suchleiste auf die Schaltfläche für Ansichtsoptionen, um zuletzt bearbeitete Notizen zuerst oder zuletzt anzuzeigen, oder wählen Sie „Eigene Reihenfolge“ und ordnen Sie Notizen selbst per Ziehen an. Anzeigeformat - Wechseln Sie zwischen Liste und Raster, um Ihre Notizen in einem für Sie bequemen Layout anzuzeigen + In denselben Ansichtsoptionen wechseln Sie zwischen Liste und Raster. Wischgesten @@ -224,6 +222,8 @@ Tags Drücken Sie diese Taste, um ein Tag zu erstellen. Tag-Optionen + Tag ändern + Notiz bearbeiten Tag erstellen Notizen mit diesem Tag werden in der Gesamtliste angezeigt. Notizen mit diesem Tag sind in der Gesamtliste verborgen. @@ -370,6 +370,7 @@ Erinnerung Morgen %1$s Bitte wähle einen zukünftigen Zeitpunkt + Der gewählte Zeitpunkt, %1$s, ist bereits vorbei. Wähle einen späteren Zeitpunkt. Neue Notiz Alle Weiter tippen… @@ -449,9 +450,9 @@ Legen Sie für jede Aufgabe eine Erinnerung fest, indem Sie auf das Glockensymbol tippen. Zur geplanten Zeit erhalten Sie eine Benachrichtigung. Tippen Sie darauf, um die Aufgabe direkt zu öffnen. Erinnerungen Notiz-Erinnerungen - Öffnen Sie eine Notiz und tippen Sie auf das Glockensymbol, um eine Erinnerung festzulegen. Wählen Sie eine Zeit über die Schnellauswahl oder die Datums- und Zeitauswahl. Zur geplanten Zeit erscheint eine Benachrichtigung. + Öffnen Sie eine Notiz, tippen Sie auf „Mehr“ und wählen Sie „Erinnerung“. Wählen Sie eine Zeit über die Schnellauswahl oder die Datums- und Zeitauswahl. Die Erinnerung erscheint oben in der Notiz; tippen Sie darauf, um sie zu ändern. Zur geplanten Zeit erscheint eine Benachrichtigung. Wiederholungsoptionen - Aktivieren Sie beim Festlegen einer Notiz-Erinnerung den Schalter „Wiederholen“, um einen Zyklus zu wählen: täglich, wöchentlich oder monatlich. Nach jeder Benachrichtigung wird die Erinnerung automatisch neu geplant. + Wählen Sie beim Festlegen einer Notiz-Erinnerung, wie sie sich wiederholt: täglich, wöchentlich, monatlich oder „Benutzerdefiniert…“ für alle paar Stunden, Tage, Wochen, Monate oder Jahre. Jede Wiederholung zählt ab der gewählten Zeit, daher kommt eine Erinnerung am 31. in kürzeren Monaten am letzten Tag und danach wieder am 31. Wiederkehrende Erinnerungen bleiben auch nach einem Neustart des Telefons erhalten. Intervall-Benachrichtigungen Für wiederkehrende Erinnerungen können Sie erneute Benachrichtigungen alle 5, 10, 15, 30 oder 60 Minuten aktivieren. Die Benachrichtigung wiederholt sich im gewählten Intervall bis zum nächsten geplanten Termin. Benachrichtigungston und Lautstärke @@ -476,4 +477,175 @@ Nach der Bestätigung der ersten Synchronisierung können Sie die Hintergrundsynchronisierung aktivieren. Sie läuft bei bestehender Internetverbindung etwa alle 6 Stunden und kann jederzeit deaktiviert werden. Konflikte lösen Wenn sich die lokale und die Drive-Version unterscheiden, wählen Sie für jeden Konflikt, ob die lokale Version oder die Version aus Google Drive behalten werden soll. + + + Lerne Encly kennen + Der Nachfolger von Meine Notizen + Vom Entwickler von Meine Notizen + Deine Notizen – verschlüsselt und nur auf deinem Handy. Kein Konto, kein Internet. + Immer verschlüsselt + Jede Notiz liegt in einer verschlüsselten Datenbank, und auch Backups sind verschlüsselt. + Offline, ohne Konto + Encly hat keine Internetberechtigung – deine Notizen verlassen das Handy nie. + Sperre per PIN und Fingerabdruck + Nur deine PIN oder dein Fingerabdruck öffnet deine Notizen. + Kostenlos und quelloffen + Jeder kann den Code prüfen. Erhältlich bei Google Play, F-Droid und GitHub. + Der Umzug kopiert deine Notizen zu Encly. In Meine Notizen wird nichts gelöscht, und Encly schickt nichts zurück. + Zieht zu Encly um + Notizen, inklusive Papierkorb + Aufgaben + Tags + Aufgabenlisten + Werden in Encly zu Tags + Bleibt in Meine Notizen + Anhänge und Bilder + Encly speichert keine Dateien + Erinnerungen + Encly hat keine Erinnerungen + Anheften von Notizen + Die Notizen selbst ziehen um, aber nicht angeheftet + Nichts – alles zieht zu Encly um. + Encly holen + Encly aktualisieren + Meine Notizen zu Encly umziehen + Notizen werden vorbereitet… + Encly öffnet sich und übernimmt alles auf einmal. Das dauert nur einen Moment. + Kostenlos bei Google Play, F-Droid und GitHub. Komm nach der Installation hierher zurück. + Diese Encly-Version kann noch keine Notizen empfangen. Aktualisiere sie und komm dann hierher zurück. + Die installierte App namens Encly ist nicht die offizielle Version, deshalb werden deine Notizen nicht an sie gesendet. Installiere Encly über Google Play, F-Droid oder GitHub. + Deine Notizen sind in Encly + Notizen + Übersprungen + In Meine Notizen verbliebene Anhänge und Bilder: %1$d. + Der Umzug wurde nicht abgeschlossen + Der Umzug wurde abgebrochen. Es wurde nichts geändert. + Encly nimmt Notizen nur von der offiziellen App Meine Notizen an. Installiere Meine Notizen über Google Play, F-Droid oder GitHub und versuche es erneut. + Dieses Encly kann die Daten dieser Version von Meine Notizen nicht lesen. Aktualisiere Encly und versuche es erneut. + Encly konnte die Daten nicht lesen. Es wurde nichts importiert. + Es sind zu viele Daten für einen Umzug auf einmal. Es wurde nichts importiert. + Encly konnte den Import nicht abschließen. In Meine Notizen wurde nichts gelöscht. + Deine Notizen konnten nicht für Encly vorbereitet werden. + Meine Notizen behält weiterhin eine eigene Kopie. Wenn du sie nicht mehr brauchst, kannst du sie leeren – vorher fragen wir noch einmal nach. + Meine Notizen leeren + Meine Notizen leeren? + Damit werden alle Notizen (einschließlich Papierkorb), Aufgaben, Tags, Aufgabenlisten und Anhangsdateien dauerhaft aus Meine Notizen gelöscht. Ist die Google-Drive-Synchronisierung aktiv, werden sie auch aus der synchronisierten Kopie gelöscht. Anhänge sind nicht zu Encly umgezogen. Das kann nicht rückgängig gemacht werden. + Alles löschen + Meine Notizen wurde geleert. + Meine Notizen konnte nicht geleert werden. + Meine Notizen hat einen Nachfolger: Encly, eine Notiz-App, die alles verschlüsselt und offline hält. Zieh alle Notizen mit einem Tippen um. + So ziehst du um + Später + Suchen + Ansichtsoptionen + Ansichtsoptionen. Notizen werden als Liste angezeigt + Ansichtsoptionen. Notizen werden als Raster angezeigt + Reihenfolge + Zuletzt bearbeitete zuerst + Zuletzt bearbeitete zuletzt + Darstellung + Liste + Raster + Lesemodus + Zurück zum Bearbeiten + Erinnerung: %1$s. Erinnerung ändern + Öffnungsmodus für Notizen + Neue Notizen öffnen sich immer bereit zum Schreiben. + Automatisch + Lesemodus im einfachen Editor, Bearbeitungsmodus im erweiterten Editor + Lesemodus + Nichts ändert sich versehentlich. Tippe auf Bearbeiten, um zu schreiben + Bearbeitungsmodus + Der Cursor ist gesetzt und die Tastatur öffnet sich + Notizen öffnen + Wo du aufgehört hast + Die Zeile, die du gelesen hast, und der Cursor dort, wo du zuletzt bearbeitet hast + Am Anfang + Immer mit der ersten Zeile beginnen + Zum Bearbeiten doppelt tippen + Tippe im Lesemodus doppelt auf den Text, um genau dort mit dem Bearbeiten zu beginnen. + Ausgewählt + + + Benutzerdefiniert… + Eigene Wiederholung + Wiederholen alle + Geben Sie eine Zahl von 1 bis 999 ein + Fertig + Eigene Wiederholung: %1$s. Ändern + Stündlich + Jährlich + Erinnerungen kommen eventuell verspätet. Erlauben Sie „Wecker und Erinnerungen“, damit sie pünktlich sind. + Erlauben + + Alle %d Stunden + Alle %d Stunden + + + Alle %d Tage + Alle %d Tage + + + Alle %d Wochen + Alle %d Wochen + + + Alle %d Monate + Alle %d Monate + + + Alle %d Jahre + Alle %d Jahre + + + %d Stunde + %d Stunden + + + %d Tag + %d Tage + + + %d Woche + %d Wochen + + + %d Monat + %d Monate + + + %d Jahr + %d Jahre + + Versionsverlauf + Frühere Versionen dieser Notiz werden nur auf diesem Gerät gespeichert, bis zu %1$d. Sie werden weder synchronisiert noch in Sicherungen aufgenommen. + Noch keine früheren Versionen + Wenn sich diese Notiz ändert, wird der vorherige Text hier aufbewahrt, damit Sie ihn zurückholen können. + Automatisch gespeichert + Vor der Synchronisierung + Vor der Wiederherstellung + Vor dem Konflikt + Gerade eben, %1$s + Diese Version + Aktuelle Notiz + Diese Version hat denselben Text wie die aktuelle Notiz. + Wiederherstellen + Diese Version wiederherstellen? + Der aktuelle Text der Notiz wird ersetzt. Er bleibt im Versionsverlauf, sodass Sie zurückwechseln können. + Anhänge, die nicht mehr in der Notiz sind, kommen mit dieser Version nicht zurück. + Version wiederhergestellt + Diese Version konnte nicht wiederhergestellt werden + Leere Notiz + Eigene Reihenfolge + Halten Sie eine Notiz gedrückt und ziehen Sie sie an eine neue Stelle. Angeheftete Notizen bleiben über den anderen. + Nach oben verschieben + Nach unten verschieben + Bereits ganz oben + Bereits ganz unten + An Position %1$d von %2$d verschoben + Rückgängig + Wiederholen + Tastatur ausblenden + Um Notizen per Ziehen zu ordnen, aktiviere „Eigene Reihenfolge“ + Aktivieren diff --git a/app/src/main/res/values-en-rGB/strings.xml b/app/src/main/res/values-en-rGB/strings.xml index 69567ba8..152d90b7 100644 --- a/app/src/main/res/values-en-rGB/strings.xml +++ b/app/src/main/res/values-en-rGB/strings.xml @@ -13,7 +13,6 @@ Repeat the action to exit Google Play app not found This content is not supported. - Format notes Search notes Close @@ -69,7 +68,6 @@ Save to My Notes "@"pasichDev pasichDev@gmail.com - EditNote Andrii Pasichnik (pasichDev) Thanks byy Slider edit text size @@ -114,7 +112,6 @@ Data restored successfully Error: backup is corrupted Server error or poor network quality - Sort notes Dear user @@ -178,9 +175,9 @@ Search Quickly find notes by title or content. Sorting - Sort notes by name or modification date. + Tap the view options button in the search bar to show recently edited notes first or last, or choose Custom order and arrange notes yourself by dragging them. Display format - Switch between list and grid view. + In the same view options, switch between a list and a grid. Swipe gestures Swipe left to enter multi-selection mode, swipe right to delete a note. Available only in single-column mode. @@ -237,6 +234,8 @@ Tags Tap this button to create a tag. Tag options + Change tag + Edit note Create tag Notes with this tag are visible in the main list. Notes with this tag are hidden from the main list. @@ -414,6 +413,7 @@ Reminder Tomorrow %1$s Please select a future time + The time you chose, %1$s, has already passed. Choose a later time. New note All Keep typing… @@ -496,9 +496,9 @@ Set a reminder for any task by tapping the bell icon. You will receive a notification at the scheduled time. Tap the notification to open the task directly. Reminders Note Reminders - Open any note and tap the bell icon to set a reminder. Pick a time using quick options or the date and time picker. A notification will appear at the scheduled time. + Open a note, tap More and choose Reminder. Pick a time using quick options or the date and time picker. The reminder appears at the top of the note; tap it to change it. A notification will appear at the scheduled time. Repeat Options - When setting a note reminder, enable the Repeat toggle to choose a repeat cycle: Daily, Weekly, or Monthly. The reminder reschedules automatically after each notification. + When setting a note reminder, choose how it repeats: Daily, Weekly, Monthly, or Custom… for every few hours, days, weeks, months or years. Each repeat is counted from the time you picked, so a reminder on the 31st rings on the last day of shorter months and is back on the 31st after them. Repeating reminders survive a restart of the phone. Interval Notifications For repeating reminders, you can enable interval re-notifications every 5, 10, 15, 30, or 60 minutes. The notification will repeat at the chosen interval until the next scheduled occurrence. Notification Sound and Volume @@ -523,4 +523,175 @@ After you confirm the first sync, you can enable background sync. It runs about every 6 hours when the device has an internet connection and can be turned off at any time. Resolve Conflicts When local and Drive versions differ, choose whether to keep the local version or the version from Google Drive for each conflict. + + + Meet Encly + The successor of My Notes + From the author of My Notes + Notes that stay encrypted and stay on your phone. No account, no internet. + Encrypted, always + Every note is kept in an encrypted database, and backups are encrypted too. + Offline, no account + Encly has no internet permission, so your notes never leave the phone. + PIN and fingerprint lock + Only your PIN or fingerprint opens your notes. + Free and open source + Anyone can check the code. Available on Google Play, F-Droid and GitHub. + Moving copies your notes to Encly. Nothing is deleted from My Notes, and Encly never sends anything back. + Moves to Encly + Notes, including the trash + Tasks + Tags + Task lists + Become tags in Encly + Stays in My Notes + Attachments and images + Encly does not store files + Reminders + Encly has no reminders + Pins on notes + The notes themselves move, unpinned + Nothing. Everything you have moves to Encly. + Get Encly + Update Encly + Move my notes to Encly + Preparing your notes… + Encly opens and takes everything in one go. It only takes a moment. + Free on Google Play, F-Droid and GitHub. Come back here once it is installed. + This version of Encly cannot receive notes yet. Update it, then come back here. + The installed app called Encly is not the official build, so your notes will not be sent to it. Install Encly from Google Play, F-Droid or GitHub. + Your notes are in Encly + Notes + Skipped + Attachments and images left in My Notes: %1$d. + The move did not finish + The move was cancelled. Nothing has changed. + Encly accepts notes only from the official My Notes app. Install My Notes from Google Play, F-Droid or GitHub and try again. + This Encly cannot read data from this version of My Notes. Update Encly and try again. + Encly could not read the data. Nothing was imported. + There is too much data to move in one go. Nothing was imported. + Encly could not finish the import. Nothing was deleted from My Notes. + Could not prepare your notes for Encly. + My Notes still keeps its own copy. If you no longer need it, you can clear it. You will be asked to confirm first. + Clear My Notes + Clear My Notes? + This permanently deletes all notes (including the trash), tasks, tags, task lists and attachment files from My Notes. If Google Drive sync is on, they are deleted from the synced copy too. Attachments did not move to Encly. This cannot be undone. + Delete everything + My Notes has been cleared. + Could not clear My Notes. + My Notes has a successor: Encly, a notes app that keeps everything encrypted and offline. Move all your notes in one tap. + See how to move + Later + Search + View options + View options. Notes are shown as a list + View options. Notes are shown as a grid + Order + Recently edited first + Recently edited last + Layout + List + Grid + Reading mode + Back to editing + Reminder: %1$s. Change reminder + Open notes in + New notes always open ready for typing. + Automatic + Reading mode in the simple editor, editing mode in the extended editor + Reading mode + Nothing changes by accident. Tap Edit to start typing + Editing mode + The cursor is in place and the keyboard opens + Open notes at + Where you left off + The line you were reading, and the cursor where you stopped editing + The beginning + Always start from the first line + Double-tap to edit + In reading mode, double-tap the text to start editing right where you tapped. + Selected + + + Custom… + Custom repeat + Repeat every + Enter a number from 1 to 999 + Done + Custom repeat: %1$s. Change + Every hour + Every year + Reminders may ring late. Allow Alarms & reminders to get them on time. + Allow + + Every %d hour + Every %d hours + + + Every %d day + Every %d days + + + Every %d week + Every %d weeks + + + Every %d month + Every %d months + + + Every %d year + Every %d years + + + %d hour + %d hours + + + %d day + %d days + + + %d week + %d weeks + + + %d month + %d months + + + %d year + %d years + + Version history + Earlier versions of this note are kept on this device only, up to %1$d. They are not synced or included in backups. + No earlier versions yet + When this note changes, its earlier text is kept here so you can bring it back. + Autosaved + Before sync + Before restore + Before conflict + Just now, %1$s + This version + Current note + This version has the same text as the current note. + Restore + Restore this version? + The note\'s current text will be replaced. It stays in version history, so you can switch back. + Attachments that are no longer in the note won\'t come back with this version. + Version restored + Couldn\'t restore this version + Empty note + Custom order + Press and hold a note, then drag it to a new place. Pinned notes stay above the others. + Move up + Move down + Already at the top + Already at the bottom + Moved to position %1$d of %2$d + Undo + Redo + Hide keyboard + To reorder notes by dragging, turn on “Custom order” + Turn on diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 20afc507..f0c09fcd 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -16,7 +16,6 @@ Abrir contactos de desarrollador Este contenido no es compatible. Falta la aplicación Play Market - Formato de notas Buscar en notas Cerrar @@ -103,7 +102,6 @@ Restauración de datos completada con éxito Error: la copia de seguridad está dañada Error del servidor o mala calidad de Internet - Ordenar notas Estimado usuario @@ -173,9 +171,9 @@ Búsqueda Usa la barra de búsqueda para encontrar rápidamente tus notas por título o contenido Ordenación - Ordena tus notas por fecha de última modificación o nombre en orden ascendente o descendente + Toca el botón de opciones de vista en la barra de búsqueda para mostrar primero o al final las notas editadas recientemente, o elige Orden personalizado y ordena las notas tú mismo arrastrándolas. Formato de visualización - Cambia entre lista y cuadrícula para mostrar tus notas en un diseño conveniente para ti + En las mismas opciones de vista, cambia entre lista y cuadrícula. Gestos de deslizar @@ -226,6 +224,8 @@ Etiquetas Presiona este botón para crear una etiqueta. Opciones de etiqueta + Cambiar etiqueta + Editar nota Crear etiqueta Las notas con esta etiqueta se muestran en la lista general. Las notas con esta etiqueta están ocultas en la lista general. @@ -371,6 +371,7 @@ Recordatorio Mañana %1$s Por favor, selecciona una hora futura + La hora elegida, %1$s, ya ha pasado. Elige una hora posterior. Nueva nota Todos Sigue escribiendo… @@ -450,9 +451,9 @@ Configura un recordatorio para cualquier tarea tocando el icono de la campana. Recibirás una notificación a la hora prevista. Tócala para abrir la tarea directamente. Recordatorios Recordatorios de notas - Abre cualquier nota y toca el icono de la campana para configurar un recordatorio. Elige la hora con las opciones rápidas o con el selector de fecha y hora. A la hora prevista aparecerá una notificación. + Abre una nota, toca «Más» y elige «Recordatorio». Elige la hora con las opciones rápidas o con el selector de fecha y hora. El recordatorio aparece en la parte superior de la nota; tócalo para cambiarlo. A la hora prevista aparecerá una notificación. Opciones de repetición - Al configurar un recordatorio de nota, activa el interruptor «Repetir» para elegir el ciclo: diario, semanal o mensual. El recordatorio se reprograma automáticamente tras cada notificación. + Al configurar un recordatorio de nota, elige cómo se repite: diariamente, semanalmente, mensualmente o «Personalizado…» para cada varias horas, días, semanas, meses o años. Cada repetición se cuenta desde la hora elegida, así que un recordatorio el día 31 suena el último día de los meses más cortos y vuelve al 31 después. Los recordatorios periódicos se conservan tras reiniciar el teléfono. Notificaciones por intervalo Para los recordatorios repetidos puedes activar una nueva notificación cada 5, 10, 15, 30 o 60 minutos. La notificación se repetirá en el intervalo elegido hasta la siguiente vez programada. Sonido y volumen de las notificaciones @@ -477,4 +478,175 @@ Después de confirmar la primera sincronización, puedes activar la sincronización en segundo plano. Se ejecuta aproximadamente cada 6 horas cuando el dispositivo tiene conexión a internet y se puede desactivar en cualquier momento. Resolver conflictos Cuando las versiones local y de Drive son distintas, elige para cada conflicto si conservar la versión local o la versión de Google Drive. + + + Conoce Encly + El sucesor de Mis notas + Del autor de Mis notas + Tus notas, cifradas y solo en tu teléfono. Sin cuenta y sin internet. + Siempre cifrado + Cada nota se guarda en una base de datos cifrada, y las copias de seguridad también van cifradas. + Sin conexión y sin cuenta + Encly no tiene permiso de internet, así que tus notas nunca salen del teléfono. + Bloqueo con PIN y huella + Solo tu PIN o tu huella abren tus notas. + Gratis y de código abierto + Cualquiera puede revisar el código. Disponible en Google Play, F-Droid y GitHub. + El traslado copia tus notas a Encly. No se borra nada de Mis notas y Encly nunca devuelve nada. + Pasa a Encly + Notas, incluida la papelera + Tareas + Etiquetas + Listas de tareas + En Encly se convierten en etiquetas + Se queda en Mis notas + Adjuntos e imágenes + Encly no guarda archivos + Recordatorios + Encly no tiene recordatorios + Fijado de notas + Las notas se trasladan, pero sin fijar + Nada: todo pasa a Encly. + Obtener Encly + Actualizar Encly + Trasladar mis notas a Encly + Preparando tus notas… + Encly se abrirá y lo recibirá todo de una vez. Solo tarda un momento. + Gratis en Google Play, F-Droid y GitHub. Vuelve aquí cuando esté instalada. + Esta versión de Encly aún no puede recibir notas. Actualízala y vuelve aquí. + La app instalada con el nombre Encly no es la versión oficial, así que no se le enviarán tus notas. Instala Encly desde Google Play, F-Droid o GitHub. + Tus notas ya están en Encly + Notas + Omitidos + Adjuntos e imágenes que quedan en Mis notas: %1$d. + El traslado no terminó + El traslado se canceló. No ha cambiado nada. + Encly solo acepta notas de la app oficial Mis notas. Instala Mis notas desde Google Play, F-Droid o GitHub e inténtalo de nuevo. + Esta versión de Encly no puede leer los datos de esta versión de Mis notas. Actualiza Encly e inténtalo de nuevo. + Encly no pudo leer los datos. No se importó nada. + Hay demasiados datos para trasladarlos de una vez. No se importó nada. + Encly no pudo terminar la importación. No se borró nada de Mis notas. + No se pudieron preparar tus notas para Encly. + Mis notas sigue guardando su propia copia. Si ya no la necesitas, puedes vaciarla; antes te pediremos confirmación. + Vaciar Mis notas + ¿Vaciar Mis notas? + Esto borra de forma permanente de Mis notas todas las notas (incluida la papelera), tareas, etiquetas, listas de tareas y archivos adjuntos. Si la sincronización con Google Drive está activada, también se borrarán de la copia sincronizada. Los adjuntos no se trasladaron a Encly. Esta acción no se puede deshacer. + Borrar todo + Mis notas se ha vaciado. + No se pudo vaciar Mis notas. + Mis notas tiene un sucesor: Encly, una app de notas que lo guarda todo cifrado y sin conexión. Traslada todas tus notas con un toque. + Ver cómo trasladarlas + Más tarde + Buscar + Opciones de vista + Opciones de vista. Las notas se muestran en lista + Opciones de vista. Las notas se muestran en cuadrícula + Orden + Editadas recientemente primero + Editadas recientemente al final + Diseño + Lista + Cuadrícula + Modo lectura + Volver a editar + Recordatorio: %1$s. Cambiar recordatorio + Abrir las notas en + Las notas nuevas siempre se abren listas para escribir. + Automático + Modo lectura en el editor simple, modo edición en el editor avanzado + Modo lectura + Nada cambia por accidente. Toca Editar para empezar a escribir + Modo edición + El cursor está colocado y se abre el teclado + Abrir las notas + Donde lo dejaste + La línea que estabas leyendo y el cursor donde dejaste de editar + Al principio + Empezar siempre por la primera línea + Tocar dos veces para editar + En modo lectura, toca dos veces el texto para empezar a editar justo donde tocaste. + Seleccionado + + + Personalizado… + Repetición personalizada + Repetir cada + Introduce un número del 1 al 999 + Listo + Repetición personalizada: %1$s. Cambiar + Cada hora + Anualmente + Los recordatorios pueden llegar tarde. Permite «Alarmas y recordatorios» para recibirlos a tiempo. + Permitir + + Cada %d hora + Cada %d horas + + + Cada %d día + Cada %d días + + + Cada %d semana + Cada %d semanas + + + Cada %d mes + Cada %d meses + + + Cada %d año + Cada %d años + + + %d hora + %d horas + + + %d día + %d días + + + %d semana + %d semanas + + + %d mes + %d meses + + + %d año + %d años + + Historial de versiones + Las versiones anteriores de esta nota se guardan solo en este dispositivo, hasta %1$d. No se sincronizan ni se incluyen en las copias de seguridad. + Aún no hay versiones anteriores + Cuando esta nota cambia, su texto anterior se guarda aquí para que puedas recuperarlo. + Guardado automático + Antes de sincronizar + Antes de restaurar + Antes del conflicto + Justo ahora, %1$s + Esta versión + Nota actual + Esta versión tiene el mismo texto que la nota actual. + Restaurar + ¿Restaurar esta versión? + El texto actual de la nota se reemplazará. Se conserva en el historial de versiones, así que podrás volver a él. + Los adjuntos que ya no están en la nota no volverán con esta versión. + Versión restaurada + No se pudo restaurar esta versión + Nota vacía + Orden personalizado + Mantén pulsada una nota y arrástrala a un nuevo lugar. Las notas fijadas se quedan encima de las demás. + Subir + Bajar + Ya está arriba del todo + Ya está abajo del todo + Movida a la posición %1$d de %2$d + Deshacer + Rehacer + Ocultar teclado + Para reordenar las notas arrastrándolas, activa «Orden personalizado» + Activar diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 59d23ae6..e63d659f 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -14,8 +14,7 @@ Ouvrir les contacts des développeurs Ce contenu n\'est pas pris en charge. PlayMarket est manquant - Notes de mise en forme - Rechercher dans les notes + Rechercher dans les notes Fermer Ajouter une balise Supprimer @@ -99,7 +98,6 @@ Restauration des données terminée avec succès Erreur: Sauvegarde corrompue Erreur de serveur ou mauvaise qualité Internet - Trier les notes Cher utilisateur @@ -168,9 +166,9 @@ Recherche Utilisez la barre de recherche pour trouver rapidement vos notes par titre ou contenu Tri - Triez vos notes par date de dernière modification ou nom dans l\'ordre croissant ou décroissant + Appuyez sur le bouton des options d’affichage dans la barre de recherche pour afficher les notes modifiées récemment en premier ou en dernier, ou choisissez Ordre personnalisé et rangez vos notes vous-même en les faisant glisser. Format d\'affichage - Basculez entre la liste et la grille pour afficher vos notes dans une mise en page qui vous convient + Dans ces mêmes options d’affichage, basculez entre la liste et la grille. Gestes de balayage @@ -221,6 +219,8 @@ Étiquettes Appuyez sur ce bouton pour créer un tag. Options de l’étiquette + Changer l’étiquette + Modifier la note Créer une étiquette Les notes marquées avec cette étiquette apparaissent dans la liste générale. Les notes marquées avec cette étiquette sont masquées dans la liste générale. @@ -366,6 +366,7 @@ Rappel Demain %1$s Veuillez sélectionner une heure future + L’heure choisie, %1$s, est déjà passée. Choisissez une heure plus tardive. Nouvelle note Tout Continuez à taper… @@ -445,9 +446,9 @@ Définissez un rappel pour n’importe quelle tâche en appuyant sur l’icône de cloche. Vous recevrez une notification à l’heure prévue. Appuyez dessus pour ouvrir directement la tâche. Rappels Rappels de notes - Ouvrez une note et appuyez sur l’icône de cloche pour définir un rappel. Choisissez une heure via les options rapides ou le sélecteur de date et d’heure. Une notification apparaîtra à l’heure prévue. + Ouvrez une note, appuyez sur « Plus » et choisissez « Rappel ». Choisissez une heure via les options rapides ou le sélecteur de date et d’heure. Le rappel s’affiche en haut de la note ; appuyez dessus pour le modifier. Une notification apparaîtra à l’heure prévue. Options de répétition - Lors de la création d’un rappel de note, activez l’option « Répéter » pour choisir un cycle : quotidien, hebdomadaire ou mensuel. Le rappel est replanifié automatiquement après chaque notification. + Lors de la création d’un rappel de note, choisissez sa répétition : quotidien, hebdomadaire, mensuel ou « Personnalisé… » pour un intervalle de plusieurs heures, jours, semaines, mois ou années. Chaque répétition est comptée à partir de l’heure choisie : un rappel le 31 sonne le dernier jour des mois plus courts, puis de nouveau le 31. Les rappels récurrents sont conservés après un redémarrage du téléphone. Notifications par intervalle Pour les rappels récurrents, vous pouvez activer une nouvelle notification toutes les 5, 10, 15, 30 ou 60 minutes. La notification se répétera à l’intervalle choisi jusqu’à la prochaine échéance. Son et volume des notifications @@ -472,4 +473,175 @@ Après avoir confirmé la première synchronisation, vous pouvez activer la synchronisation en arrière-plan. Elle s’exécute environ toutes les 6 heures lorsque l’appareil est connecté à Internet et peut être désactivée à tout moment. Résoudre les conflits Lorsque les versions locale et Drive diffèrent, choisissez pour chaque conflit de conserver la version locale ou celle de Google Drive. + + + Découvrez Encly + Le successeur de Mes notes + Par l\'auteur de Mes notes + Vos notes, chiffrées et uniquement sur votre téléphone. Sans compte, sans Internet. + Toujours chiffré + Chaque note est stockée dans une base de données chiffrée, et les sauvegardes sont chiffrées elles aussi. + Hors ligne, sans compte + Encly n\'a pas l\'autorisation d\'accéder à Internet : vos notes ne quittent jamais le téléphone. + Verrouillage par code PIN et empreinte + Seuls votre code PIN ou votre empreinte ouvrent vos notes. + Gratuit et open source + Tout le monde peut vérifier le code. Disponible sur Google Play, F-Droid et GitHub. + Le transfert copie vos notes vers Encly. Rien n\'est supprimé de Mes notes, et Encly ne renvoie jamais rien. + Part vers Encly + Notes, corbeille comprise + Tâches + Étiquettes + Listes de tâches + Deviennent des étiquettes dans Encly + Reste dans Mes notes + Pièces jointes et images + Encly ne stocke pas de fichiers + Rappels + Encly n\'a pas de rappels + Épinglage des notes + Les notes elles-mêmes sont transférées, mais désépinglées + Rien : tout part vers Encly. + Obtenir Encly + Mettre à jour Encly + Transférer mes notes vers Encly + Préparation de vos notes… + Encly s\'ouvre et récupère tout d\'un coup. Cela ne prend qu\'un instant. + Gratuit sur Google Play, F-Droid et GitHub. Revenez ici une fois l\'app installée. + Cette version d\'Encly ne peut pas encore recevoir de notes. Mettez-la à jour, puis revenez ici. + L\'app installée sous le nom Encly n\'est pas la version officielle : vos notes ne lui seront pas envoyées. Installez Encly depuis Google Play, F-Droid ou GitHub. + Vos notes sont dans Encly + Notes + Ignorés + Pièces jointes et images restées dans Mes notes : %1$d. + Le transfert n\'a pas abouti + Le transfert a été annulé. Rien n\'a changé. + Encly n\'accepte les notes que de l\'application officielle Mes notes. Installez Mes notes depuis Google Play, F-Droid ou GitHub, puis réessayez. + Cette version d\'Encly ne peut pas lire les données de cette version de Mes notes. Mettez Encly à jour, puis réessayez. + Encly n\'a pas pu lire les données. Rien n\'a été importé. + Il y a trop de données pour un seul transfert. Rien n\'a été importé. + Encly n\'a pas pu terminer l\'importation. Rien n\'a été supprimé de Mes notes. + Impossible de préparer vos notes pour Encly. + Mes notes conserve toujours sa propre copie. Si vous n\'en avez plus besoin, vous pouvez la vider ; une confirmation vous sera demandée. + Vider Mes notes + Vider Mes notes ? + Cette action supprime définitivement de Mes notes toutes les notes (corbeille comprise), les tâches, les étiquettes, les listes de tâches et les fichiers joints. Si la synchronisation Google Drive est activée, ils seront aussi supprimés de la copie synchronisée. Les pièces jointes n\'ont pas été transférées vers Encly. Cette action est irréversible. + Tout supprimer + Mes notes a été vidé. + Impossible de vider Mes notes. + Mes notes a un successeur : Encly, une app de notes qui garde tout chiffré et hors ligne. Transférez toutes vos notes en un geste. + Voir comment transférer + Plus tard + Rechercher + Options d’affichage + Options d’affichage. Les notes sont affichées en liste + Options d’affichage. Les notes sont affichées en grille + Ordre + Modifiées récemment en premier + Modifiées récemment en dernier + Disposition + Liste + Grille + Mode lecture + Revenir à l’édition + Rappel : %1$s. Modifier le rappel + Ouvrir les notes en + Les nouvelles notes s\'ouvrent toujours prêtes à la saisie. + Automatique + Mode lecture dans l\'éditeur simple, mode édition dans l\'éditeur avancé + Mode lecture + Rien ne change par erreur. Touchez Modifier pour commencer à écrire + Mode édition + Le curseur est placé et le clavier s\'ouvre + Ouvrir les notes + Là où vous vous êtes arrêté + La ligne que vous lisiez, et le curseur là où vous avez arrêté de modifier + Au début + Toujours commencer par la première ligne + Toucher deux fois pour modifier + En mode lecture, touchez deux fois le texte pour commencer à le modifier exactement à cet endroit. + Sélectionné + + + Personnalisé… + Répétition personnalisée + Intervalle + Saisissez un nombre entre 1 et 999 + OK + Répétition personnalisée : %1$s. Modifier + Toutes les heures + Tous les ans + Les rappels risquent d’arriver en retard. Autorisez « Alarmes et rappels » pour les recevoir à l’heure. + Autoriser + + Toutes les %d heures + Toutes les %d heures + + + Tous les %d jours + Tous les %d jours + + + Toutes les %d semaines + Toutes les %d semaines + + + Tous les %d mois + Tous les %d mois + + + Tous les %d ans + Tous les %d ans + + + %d heure + %d heures + + + %d jour + %d jours + + + %d semaine + %d semaines + + + %d mois + %d mois + + + %d an + %d ans + + Historique des versions + Les versions précédentes de cette note sont conservées uniquement sur cet appareil, jusqu\'à %1$d. Elles ne sont ni synchronisées ni incluses dans les sauvegardes. + Aucune version précédente pour l\'instant + Quand cette note change, son texte précédent est conservé ici pour que vous puissiez le récupérer. + Enregistrement automatique + Avant la synchronisation + Avant la restauration + Avant le conflit + À l’instant, %1$s + Cette version + Note actuelle + Cette version a le même texte que la note actuelle. + Restaurer + Restaurer cette version ? + Le texte actuel de la note sera remplacé. Il reste dans l\'historique des versions, vous pourrez donc y revenir. + Les pièces jointes qui ne sont plus dans la note ne reviendront pas avec cette version. + Version restaurée + Impossible de restaurer cette version + Note vide + Ordre personnalisé + Appuyez de manière prolongée sur une note, puis faites-la glisser vers un nouvel emplacement. Les notes épinglées restent au-dessus des autres. + Monter + Descendre + Déjà en haut + Déjà en bas + Déplacée en position %1$d sur %2$d + Annuler + Rétablir + Masquer le clavier + Pour réordonner les notes en les faisant glisser, activez « Ordre personnalisé » + Activer diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 0ce04eb4..28cf66a7 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -14,8 +14,7 @@ Apri contatti sviluppatore Questo contenuto non è supportato. Manca Play Market - Formatta le note - Cerca nelle note + Cerca nelle note Chiudi Aggiungi etichetta Elimina @@ -99,7 +98,6 @@ Errore: backup corrotto Errore del server o scarsa qualità di Internet - Ordina note Gentile utente @@ -168,9 +166,9 @@ Ricerca Usa la barra di ricerca per trovare rapidamente le tue note per titolo o contenuto Ordinamento - Ordina le tue note per data dell\'ultima modifica o nome in ordine crescente o decrescente + Tocca il pulsante delle opzioni di visualizzazione nella barra di ricerca per mostrare per prime o per ultime le note modificate di recente, oppure scegli Ordine personalizzato e disponi le note tu stesso trascinandole. Formato di visualizzazione - Passa tra lista e griglia per visualizzare le tue note in un layout comodo per te + Nelle stesse opzioni di visualizzazione, passa tra elenco e griglia. Gesti di scorrimento @@ -221,6 +219,8 @@ Tag Premi questo pulsante per creare un tag. Opzioni del tag + Cambia tag + Modifica nota Crea tag Le note con questo tag vengono visualizzate nell’elenco generale. Le note con questo tag sono nascoste nell’elenco generale. @@ -368,6 +368,7 @@ Promemoria Domani %1$s Seleziona un orario futuro + L’orario scelto, %1$s, è già passato. Scegli un orario successivo. Nuova nota Tutte Continua a digitare… @@ -447,9 +448,9 @@ Imposta un promemoria per qualsiasi attività toccando l’icona della campanella. Riceverai una notifica all’ora prevista. Toccala per aprire direttamente l’attività. Promemoria Promemoria delle note - Apri una nota e tocca l’icona della campanella per impostare un promemoria. Scegli l’orario con le opzioni rapide o con il selettore di data e ora. All’ora prevista comparirà una notifica. + Apri una nota, tocca «Altro» e scegli «Promemoria». Scegli l’orario con le opzioni rapide o con il selettore di data e ora. Il promemoria compare in cima alla nota; toccalo per modificarlo. All’ora prevista comparirà una notifica. Opzioni di ripetizione - Quando imposti un promemoria per una nota, attiva l’interruttore «Ripeti» per scegliere il ciclo: giornaliero, settimanale o mensile. Il promemoria viene riprogrammato automaticamente dopo ogni notifica. + Quando imposti un promemoria per una nota, scegli come si ripete: giornaliero, settimanale, mensile o «Personalizzato…» per ogni tot ore, giorni, settimane, mesi o anni. Ogni ripetizione si conta dall’ora scelta, quindi un promemoria il 31 suona l’ultimo giorno dei mesi più brevi e poi di nuovo il 31. I promemoria ricorrenti restano attivi anche dopo il riavvio del telefono. Notifiche a intervalli Per i promemoria ricorrenti puoi attivare una nuova notifica ogni 5, 10, 15, 30 o 60 minuti. La notifica si ripeterà all’intervallo scelto fino alla scadenza successiva. Suono e volume delle notifiche @@ -474,4 +475,175 @@ Dopo aver confermato la prima sincronizzazione, puoi attivare la sincronizzazione in background. Viene eseguita circa ogni 6 ore quando il dispositivo dispone di una connessione Internet e può essere disattivata in qualsiasi momento. Risolvi conflitti Quando le versioni locale e Drive differiscono, scegli per ogni conflitto se mantenere la versione locale o quella di Google Drive. + + + Scopri Encly + Il successore di Le mie note + Dall\'autore di Le mie note + Le tue note, cifrate e solo sul tuo telefono. Nessun account, niente internet. + Sempre cifrate + Ogni nota è conservata in un database cifrato, e anche i backup sono cifrati. + Offline, senza account + Encly non ha il permesso di accedere a internet: le tue note non lasciano mai il telefono. + Blocco con PIN e impronta + Solo il tuo PIN o la tua impronta aprono le tue note. + Gratuita e open source + Chiunque può verificare il codice. Disponibile su Google Play, F-Droid e GitHub. + Il trasferimento copia le tue note su Encly. Da Le mie note non viene eliminato nulla, ed Encly non invia mai nulla indietro. + Passa su Encly + Note, cestino compreso + Attività + Tag + Elenchi di attività + Su Encly diventano tag + Resta in Le mie note + Allegati e immagini + Encly non salva file + Promemoria + Encly non ha promemoria + Fissaggio delle note + Le note stesse vengono trasferite, ma non fissate + Niente: tutto passa su Encly. + Scarica Encly + Aggiorna Encly + Trasferisci le mie note su Encly + Preparazione delle note… + Si aprirà Encly e riceverà tutto in una volta. Ci vuole solo un attimo. + Gratis su Google Play, F-Droid e GitHub. Torna qui dopo averla installata. + Questa versione di Encly non può ancora ricevere note. Aggiornala e torna qui. + L\'app installata con il nome Encly non è la versione ufficiale, quindi le tue note non le verranno inviate. Installa Encly da Google Play, F-Droid o GitHub. + Le tue note sono su Encly + Note + Saltati + Allegati e immagini rimasti in Le mie note: %1$d. + Il trasferimento non è stato completato + Il trasferimento è stato annullato. Non è cambiato nulla. + Encly accetta note solo dall\'app ufficiale Le mie note. Installa Le mie note da Google Play, F-Droid o GitHub e riprova. + Questa versione di Encly non può leggere i dati di questa versione di Le mie note. Aggiorna Encly e riprova. + Encly non è riuscito a leggere i dati. Non è stato importato nulla. + Ci sono troppi dati da trasferire in una volta sola. Non è stato importato nulla. + Encly non è riuscito a completare l\'importazione. Da Le mie note non è stato eliminato nulla. + Impossibile preparare le note per Encly. + Le mie note conserva ancora una propria copia. Se non ti serve più, puoi svuotarla: prima ti chiederemo conferma. + Svuota Le mie note + Svuotare Le mie note? + Verranno eliminati definitivamente da Le mie note tutte le note (cestino compreso), le attività, i tag, gli elenchi di attività e i file allegati. Se la sincronizzazione con Google Drive è attiva, verranno eliminati anche dalla copia sincronizzata. Gli allegati non sono stati trasferiti su Encly. L\'operazione non può essere annullata. + Elimina tutto + Le mie note è stata svuotata. + Impossibile svuotare Le mie note. + Le mie note ha un successore: Encly, un\'app per le note che tiene tutto cifrato e offline. Trasferisci tutte le note con un tocco. + Scopri come trasferirle + Più tardi + Cerca + Opzioni di visualizzazione + Opzioni di visualizzazione. Le note sono mostrate come elenco + Opzioni di visualizzazione. Le note sono mostrate come griglia + Ordine + Modificate di recente per prime + Modificate di recente per ultime + Disposizione + Elenco + Griglia + Modalità lettura + Torna alla modifica + Promemoria: %1$s. Modifica promemoria + Apri le note in + Le nuove note si aprono sempre pronte per scrivere. + Automatico + Modalità lettura nell\'editor semplice, modalità modifica nell\'editor avanzato + Modalità lettura + Niente cambia per sbaglio. Tocca Modifica per iniziare a scrivere + Modalità modifica + Il cursore è al suo posto e la tastiera si apre + Apri le note + Dove eri rimasto + La riga che stavi leggendo e il cursore dove hai smesso di modificare + Dall\'inizio + Inizia sempre dalla prima riga + Doppio tocco per modificare + In modalità lettura, tocca due volte il testo per iniziare a modificarlo proprio lì. + Selezionato + + + Personalizzato… + Ripetizione personalizzata + Ripeti ogni + Inserisci un numero da 1 a 999 + Fine + Ripetizione personalizzata: %1$s. Modifica + Ogni ora + Ogni anno + I promemoria potrebbero arrivare in ritardo. Consenti «Sveglie e promemoria» per riceverli in orario. + Consenti + + Ogni %d ora + Ogni %d ore + + + Ogni %d giorno + Ogni %d giorni + + + Ogni %d settimana + Ogni %d settimane + + + Ogni %d mese + Ogni %d mesi + + + Ogni %d anno + Ogni %d anni + + + %d ora + %d ore + + + %d giorno + %d giorni + + + %d settimana + %d settimane + + + %d mese + %d mesi + + + %d anno + %d anni + + Cronologia versioni + Le versioni precedenti di questa nota sono conservate solo su questo dispositivo, fino a %1$d. Non vengono sincronizzate né incluse nei backup. + Ancora nessuna versione precedente + Quando questa nota cambia, il testo precedente viene conservato qui per poterlo recuperare. + Salvataggio automatico + Prima della sincronizzazione + Prima del ripristino + Prima del conflitto + Proprio ora, %1$s + Questa versione + Nota attuale + Questa versione ha lo stesso testo della nota attuale. + Ripristina + Ripristinare questa versione? + Il testo attuale della nota verrà sostituito. Resta nella cronologia versioni, quindi potrai tornarci. + Gli allegati che non sono più nella nota non torneranno con questa versione. + Versione ripristinata + Impossibile ripristinare questa versione + Nota vuota + Ordine personalizzato + Tieni premuta una nota, poi trascinala in una nuova posizione. Le note fissate restano sopra le altre. + Sposta su + Sposta giù + Già in cima + Già in fondo + Spostata in posizione %1$d di %2$d + Annulla + Ripeti + Nascondi tastiera + Per riordinare le note trascinandole, attiva «Ordine personalizzato» + Attiva diff --git a/app/src/main/res/values-kk/strings.xml b/app/src/main/res/values-kk/strings.xml index 509e1c6e..02e35d50 100644 --- a/app/src/main/res/values-kk/strings.xml +++ b/app/src/main/res/values-kk/strings.xml @@ -14,8 +14,7 @@ Әзірлеуші ​​контактілерін ашыңыз Бұл мазмұнға қолдау көрсетілмейді. Play Market қолданбасы жоқ - Жазбаларды пішімдеу - Ескертпелердегі іздеу + Жазбалардан іздеу Жабу Тег қосу Текті @@ -101,7 +100,6 @@ Қате: сақтық көшірме бүлінген Сервер қатесі немесе нашар интернет сапасы - Ескертпелерді сұрыптау Құрметті пайдаланушы Жоба әзірлеушісі сатып алғаныңыз үшін алғыс білдіреді. Біз жақсырақ және ыңғайлырақ болуға тырысамыз. \n Құрметпен, менің жазбаларым! @@ -170,9 +168,9 @@ Іздеу Жазбаларыңызды атауы немесе мазмұны бойынша тез табу үшін іздеу жолағын пайдаланыңыз Сұрыптау - Жазбаларыңызды соңғы өзгерту күні немесе атауы бойынша өсу немесе кему ретімен сұрыптаңыз + Жақында өзгертілген жазбаларды басында немесе соңында көрсету үшін іздеу жолағындағы көрініс параметрлері түймесін басыңыз немесе «Өз ретім» тармағын таңдап, жазбаларды сүйреп өзіңіз орналастырыңыз. Көрсету форматы - Жазбаларыңызды өзіңізге ыңғайлы орналасуда көрсету үшін тізім мен тор арасында ауысыңыз + Сол көрініс параметрлерінде тізім мен тор арасында ауысыңыз. Сырғыту жестері @@ -223,6 +221,8 @@ Тегтер Тег жасау үшін осы батырманы басыңыз. Тег параметрлері + Тегті өзгерту + Жазбаны өңдеу Тег жасау Осы тегпен белгіленген жазбалар жалпы тізімде көрсетіледі. Осы тегпен белгіленген жазбалар жалпы тізімде жасырылған. @@ -367,6 +367,7 @@ Еске салу Ертең %1$s Болашақ уақытты таңдаңыз + Таңдалған уақыт, %1$s, өтіп кетті. Кейінірек уақытты таңдаңыз. Жаңа жазба Барлығы Теруді жалғастырыңыз… @@ -446,9 +447,9 @@ Қоңырау белгішесін басып, кез келген тапсырмаға еске салғыш қойыңыз. Белгіленген уақытта хабарландыру келеді. Тапсырманы бірден ашу үшін хабарландыруды басыңыз. Еске салғыштар Жазба еске салғыштары - Кез келген жазбаны ашып, еске салғыш қою үшін қоңырау белгішесін басыңыз. Уақытты жылдам нұсқалар арқылы немесе күн мен уақытты таңдау терезесінен көрсетіңіз. Белгіленген уақытта хабарландыру шығады. + Жазбаны ашып, «Қосымша» түймесін басып, «Еске салу» тармағын таңдаңыз. Уақытты жылдам нұсқалар арқылы немесе күн мен уақытты таңдау терезесінен көрсетіңіз. Еске салу жазбаның жоғарғы жағында көрсетіледі, өзгерту үшін оны басыңыз. Белгіленген уақытта хабарландыру шығады. Қайталау параметрлері - Жазбаға еске салғыш қойғанда қайталау циклін таңдау үшін «Қайталау» қосқышын қосыңыз: күн сайын, апта сайын немесе ай сайын. Әр хабарландырудан кейін еске салғыш автоматты түрде қайта жоспарланады. + Жазбаға еске салғыш қойғанда оның қалай қайталанатынын таңдаңыз: күн сайын, апта сайын, ай сайын немесе «Басқаша…» — бірнеше сағат, күн, апта, ай не жыл сайын. Әр қайталау таңдалған уақыттан бастап саналады, сондықтан айдың 31-іне қойылған еске салғыш қысқа айларда соңғы күні, кейін қайтадан 31-інде келеді. Қайталанатын еске салғыштар телефон қайта іске қосылғаннан кейін де сақталады. Аралық хабарландырулар Қайталанатын еске салғыштар үшін әр 5, 10, 15, 30 немесе 60 минут сайын қайта хабарлауды қосуға болады. Хабарландыру келесі жоспарланған уақытқа дейін таңдалған аралықпен қайталанады. Хабарландыру дыбысы мен қаттылығы @@ -473,4 +474,175 @@ Алғашқы синхрондауды растағаннан кейін фондық синхрондауды қоса аласыз. Ол құрылғы интернетке қосылған кезде шамамен әр 6 сағат сайын орындалады және оны кез келген уақытта өшіруге болады. Қайшылықтарды шешу Жергілікті және Drive нұсқалары әртүрлі болса, әр қайшылық үшін жергілікті нұсқаны не Google Drive нұсқасын сақтауды таңдаңыз. + + + Encly-мен танысыңыз + «Менің жазбаларым» қолданбасының мұрагері + «Менің жазбаларым» авторынан + Жазбаларыңыз шифрланған және тек телефоныңызда. Аккаунтсыз, интернетсіз. + Әрдайым шифрланған + Әр жазба шифрланған дерекқорда сақталады, сақтық көшірмелер де шифрланады. + Офлайн, аккаунтсыз + Encly-де интернетке рұқсат жоқ, сондықтан жазбалар телефоннан ешқашан шықпайды. + PIN-код пен саусақ ізімен құлыптау + Жазбаларды тек сіздің PIN-кодыңыз не саусақ ізіңіз ашады. + Тегін әрі ашық кодты + Кодты кез келген адам тексере алады. Google Play, F-Droid және GitHub-та бар. + Көшіру жазбаларды Encly-ге көшіреді. «Менің жазбаларым» қолданбасынан ештеңе жойылмайды, ал Encly ештеңені кері жібермейді. + Encly-ге көшеді + Жазбалар, себетті қоса + Тапсырмалар + Тегтер + Тапсырмалар тізімдері + Encly-де тегтерге айналады + «Менің жазбаларым» ішінде қалады + Тіркемелер мен суреттер + Encly файлдарды сақтамайды + Еске салулар + Encly-де еске салулар жоқ + Жазбаларды бекіту + Жазбалардың өзі көшеді, бірақ бекітілмеген күйде + Ештеңе — бәрі Encly-ге көшеді. + Encly алу + Encly жаңарту + Жазбаларымды Encly-ге көшіру + Жазбалар дайындалуда… + Encly ашылып, бәрін бірден қабылдайды. Бұл бір сәтке ғана созылады. + Google Play, F-Droid және GitHub-та тегін. Орнатқаннан кейін осында оралыңыз. + Encly-дің бұл нұсқасы әлі жазбаларды қабылдай алмайды. Оны жаңартып, осында оралыңыз. + Encly деп аталатын орнатылған қолданба ресми нұсқа емес, сондықтан жазбалар оған жіберілмейді. Encly-ді Google Play, F-Droid немесе GitHub арқылы орнатыңыз. + Жазбаларыңыз енді Encly-де + Жазбалар + Өткізіліп жіберілді + «Менің жазбаларым» қолданбасында қалған тіркемелер мен суреттер: %1$d. + Көшіру аяқталмады + Көшіру тоқтатылды. Ештеңе өзгерген жоқ. + Encly жазбаларды тек ресми «Менің жазбаларым» қолданбасынан қабылдайды. «Менің жазбаларым» қолданбасын Google Play, F-Droid немесе GitHub арқылы орнатып, қайталап көріңіз. + Бұл Encly нұсқасы «Менің жазбаларым» қолданбасының осы нұсқасының деректерін оқи алмайды. Encly-ді жаңартып, қайталап көріңіз. + Encly деректерді оқи алмады. Ештеңе импортталған жоқ. + Бір реттік көшіру үшін деректер тым көп. Ештеңе импортталған жоқ. + Encly импортты аяқтай алмады. «Менің жазбаларым» қолданбасынан ештеңе жойылған жоқ. + Жазбаларды Encly үшін дайындау мүмкін болмады. + «Менің жазбаларым» әлі де өз көшірмесін сақтайды. Ол қажет болмаса, оны тазалауға болады — алдымен растауды сұраймыз. + «Менің жазбаларым» тазалау + «Менің жазбаларым» тазаланбақ па? + Бұл «Менің жазбаларым» қолданбасынан барлық жазбаларды (себетті қоса), тапсырмаларды, тегтерді, тапсырмалар тізімдерін және тіркеме файлдарын біржола жояды. Google Drive синхрондауы қосулы болса, олар синхрондалған көшірмеден де жойылады. Тіркемелер Encly-ге көшірілген жоқ. Бұл әрекетті болдырмау мүмкін емес. + Барлығын жою + «Менің жазбаларым» тазаланды. + «Менің жазбаларым» тазалау мүмкін болмады. + «Менің жазбаларым» қолданбасының мұрагері бар — Encly: бәрін шифрланған күйде әрі офлайн сақтайтын жазбалар қолданбасы. Барлық жазбаны бір түртумен көшіріңіз. + Қалай көшіру керек + Кейінірек + Іздеу + Көрініс параметрлері + Көрініс параметрлері. Жазбалар тізім түрінде көрсетілген + Көрініс параметрлері. Жазбалар тор түрінде көрсетілген + Реті + Алдымен жақында өзгертілгендер + Жақында өзгертілгендер соңында + Орналасу + Тізім + Тор + Оқу режимі + Өңдеуге оралу + Еске салу: %1$s. Еске салуды өзгерту + Жазбаларды ашу режимі + Жаңа жазбалар әрқашан теруге дайын күйде ашылады. + Автоматты + Қарапайым редакторда оқу режимі, кеңейтілген редакторда өңдеу режимі + Оқу режимі + Ештеңе кездейсоқ өзгермейді. Теруді бастау үшін «Өңдеу» түймесін түртіңіз + Өңдеу режимі + Курсор орнында, пернетақта ашылады + Жазбаларды ашу орны + Тоқтаған жерден + Сіз оқыған жолдан, курсор өңдеуді тоқтатқан жерде + Басынан + Әрқашан бірінші жолдан бастау + Өңдеу үшін екі рет түрту + Оқу режимінде мәтінді екі рет түртіп, дәл сол жерден өңдеуді бастаңыз. + Таңдалған + + + Басқаша… + Қайталауды баптау + Қайталау аралығы + 1-ден 999-ға дейінгі санды енгізіңіз + Дайын + Қайталауды баптау: %1$s. Өзгерту + Сағат сайын + Жыл сайын + Еске салғыштар кешігуі мүмкін. Уақытында келуі үшін «Оятқыштар мен еске салғыштар» рұқсатын беріңіз. + Рұқсат беру + + %d сағат сайын + %d сағат сайын + + + %d күн сайын + %d күн сайын + + + %d апта сайын + %d апта сайын + + + %d ай сайын + %d ай сайын + + + %d жыл сайын + %d жыл сайын + + + %d сағат + %d сағат + + + %d күн + %d күн + + + %d апта + %d апта + + + %d ай + %d ай + + + %d жыл + %d жыл + + Нұсқалар тарихы + Осы жазбаның бұрынғы нұсқалары тек осы құрылғыда сақталады, ең көбі %1$d. Олар синхрондалмайды және сақтық көшірмелерге кірмейді. + Әзірге бұрынғы нұсқалар жоқ + Жазба өзгергенде, оның бұрынғы мәтіні қайтару үшін осында сақталады. + Автосақтау + Синхрондау алдында + Қалпына келтіру алдында + Қайшылық алдында + Жаңа ғана, %1$s + Осы нұсқа + Ағымдағы жазба + Бұл нұсқаның мәтіні ағымдағы жазбамен бірдей. + Қалпына келтіру + Осы нұсқаны қалпына келтіру керек пе? + Жазбаның ағымдағы мәтіні ауыстырылады. Ол нұсқалар тарихында қалады, сондықтан оған қайта орала аласыз. + Жазбада енді жоқ тіркемелер осы нұсқамен бірге қайтып келмейді. + Нұсқа қалпына келтірілді + Бұл нұсқаны қалпына келтіру мүмкін болмады + Бос жазба + Өз ретім + Жазбаны басып тұрып, оны жаңа орынға сүйреңіз. Бекітілген жазбалар басқалардан жоғары тұрады. + Жоғары жылжыту + Төмен жылжыту + Қазірдің өзінде ең жоғарыда + Қазірдің өзінде ең төменде + %2$d ішінен %1$d орынға жылжытылды + Болдырмау + Қайталау + Пернетақтаны жасыру + Жазбаларды сүйреп ретін өзгерту үшін «Өз ретім» параметрін қосыңыз + Қосу diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 3d704560..2a3d154d 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -14,7 +14,6 @@ Otwórz kontakty programistów Ta zawartość nie jest obsługiwana. Brak aplikacji Play Market - Formatuj notatki Szukaj w notatkach Zamknij @@ -103,7 +102,6 @@ Błąd: Uszkodzona kopia zapasowa Błąd serwera lub niska jakość internetu - Sortuj notatki Drogi użytkowniku @@ -172,9 +170,9 @@ Wyszukiwanie Użyj paska wyszukiwania, aby szybko znaleźć swoje notatki według tytułu lub zawartości Sortowanie - Sortuj swoje notatki według daty ostatniej modyfikacji lub nazwy w kolejności rosnącej lub malejącej + Naciśnij przycisk opcji widoku na pasku wyszukiwania, aby wyświetlać ostatnio edytowane notatki na początku lub na końcu, albo wybierz Własną kolejność i układaj notatki samodzielnie, przeciągając je. Format wyświetlania - Przełączaj między listą a siatką, aby wyświetlać swoje notatki w wygodnym dla siebie układzie + W tych samych opcjach widoku przełączaj między listą a siatką. Gesty przesuwania @@ -225,6 +223,8 @@ Tagi Naciśnij ten przycisk, aby utworzyć tag. Opcje tagu + Zmień tag + Edytuj notatkę Utwórz tag Notatki z tym tagiem są wyświetlane na liście ogólnej. Notatki z tym tagiem są ukryte na liście ogólnej. @@ -369,6 +369,7 @@ Przypomnienie Jutro %1$s Wybierz przyszłą datę i godzinę + Wybrany czas, %1$s, już minął. Wybierz późniejszy termin. Nowa notatka Wszystkie Pisz dalej… @@ -450,9 +451,9 @@ Ustaw przypomnienie dla dowolnego zadania, naciskając ikonę dzwonka. O wyznaczonej porze otrzymasz powiadomienie. Naciśnij je, aby od razu otworzyć zadanie. Przypomnienia Przypomnienia o notatkach - Otwórz dowolną notatkę i naciśnij ikonę dzwonka, aby ustawić przypomnienie. Wybierz godzinę za pomocą opcji szybkiego wyboru albo selektora daty i godziny. O wyznaczonej porze pojawi się powiadomienie. + Otwórz notatkę, naciśnij „Więcej” i wybierz „Przypomnienie”. Wybierz godzinę za pomocą opcji szybkiego wyboru albo selektora daty i godziny. Przypomnienie pojawi się na górze notatki; naciśnij je, aby je zmienić. O wyznaczonej porze pojawi się powiadomienie. Opcje powtarzania - Podczas ustawiania przypomnienia do notatki włącz przełącznik „Powtarzaj”, aby wybrać cykl: codziennie, co tydzień lub co miesiąc. Po każdym powiadomieniu przypomnienie jest planowane ponownie automatycznie. + Podczas ustawiania przypomnienia do notatki wybierz, jak ma się powtarzać: codziennie, co tydzień, co miesiąc lub „Własne…” – co kilka godzin, dni, tygodni, miesięcy albo lat. Każde powtórzenie liczy się od wybranej godziny, więc przypomnienie ustawione na 31. dzień w krótszych miesiącach przyjdzie ostatniego dnia, a potem znów 31. Powtarzające się przypomnienia przetrwają ponowne uruchomienie telefonu. Powiadomienia interwałowe Dla powtarzających się przypomnień możesz włączyć ponowne powiadamianie co 5, 10, 15, 30 lub 60 minut. Powiadomienie będzie powtarzane w wybranym odstępie aż do następnego terminu. Dźwięk i głośność powiadomień @@ -477,4 +478,195 @@ Po potwierdzeniu pierwszej synchronizacji możesz włączyć synchronizację w tle. Uruchamia się ona mniej więcej co 6 godzin, gdy urządzenie ma połączenie z internetem, i można ją wyłączyć w dowolnym momencie. Rozwiązywanie konfliktów Gdy wersje lokalna i Drive się różnią, dla każdego konfliktu wybierz, czy zachować wersję lokalną, czy wersję z Google Drive. + + + Poznaj Encly + Następca aplikacji Moje notatki + Od autora Moich notatek + Twoje notatki – zaszyfrowane i tylko na Twoim telefonie. Bez konta i bez internetu. + Zawsze zaszyfrowane + Każda notatka jest przechowywana w zaszyfrowanej bazie danych, a kopie zapasowe też są zaszyfrowane. + Offline, bez konta + Encly nie ma uprawnienia do internetu, więc notatki nigdy nie opuszczają telefonu. + Blokada PIN-em i odciskiem palca + Notatki otworzy tylko Twój PIN lub odcisk palca. + Darmowa i open source + Każdy może sprawdzić kod. Dostępna w Google Play, F-Droid i na GitHubie. + Przeniesienie kopiuje notatki do Encly. Z Moich notatek nic nie jest usuwane, a Encly niczego nie odsyła. + Trafi do Encly + Notatki, łącznie z koszem + Zadania + Tagi + Listy zadań + W Encly staną się tagami + Zostanie w Moich notatkach + Załączniki i obrazy + Encly nie przechowuje plików + Przypomnienia + Encly nie ma przypomnień + Przypięcie notatek + Same notatki zostaną przeniesione, ale nieprzypięte + Nic – wszystko trafi do Encly. + Pobierz Encly + Zaktualizuj Encly + Przenieś moje notatki do Encly + Przygotowujemy notatki… + Otworzy się Encly i przejmie wszystko za jednym razem. To zajmie tylko chwilę. + Za darmo w Google Play, F-Droid i na GitHubie. Po instalacji wróć tutaj. + Ta wersja Encly nie umie jeszcze przyjmować notatek. Zaktualizuj ją i wróć tutaj. + Zainstalowana aplikacja o nazwie Encly nie jest oficjalną wersją, więc notatki nie zostaną do niej wysłane. Zainstaluj Encly z Google Play, F-Droid lub GitHuba. + Twoje notatki są już w Encly + Notatki + Pominięte + Załączniki i obrazy pozostawione w Moich notatkach: %1$d. + Przenoszenie nie zostało ukończone + Przenoszenie zostało anulowane. Nic się nie zmieniło. + Encly przyjmuje notatki tylko z oficjalnej aplikacji Moje notatki. Zainstaluj Moje notatki z Google Play, F-Droid lub GitHub i spróbuj ponownie. + Ta wersja Encly nie potrafi odczytać danych z tej wersji Moich notatek. Zaktualizuj Encly i spróbuj ponownie. + Encly nie mógł odczytać danych. Nic nie zostało zaimportowane. + Danych jest za dużo, by przenieść je za jednym razem. Nic nie zostało zaimportowane. + Encly nie mógł dokończyć importu. Z Moich notatek nic nie zostało usunięte. + Nie udało się przygotować notatek dla Encly. + Moje notatki nadal przechowują własną kopię. Jeśli nie jest już potrzebna, możesz ją wyczyścić – najpierw poprosimy o potwierdzenie. + Wyczyść Moje notatki + Wyczyścić Moje notatki? + Spowoduje to trwałe usunięcie z Moich notatek wszystkich notatek (łącznie z koszem), zadań, tagów, list zadań i plików załączników. Jeśli synchronizacja z Dyskiem Google jest włączona, zostaną one usunięte także z synchronizowanej kopii. Załączniki nie zostały przeniesione do Encly. Tej operacji nie można cofnąć. + Usuń wszystko + Moje notatki zostały wyczyszczone. + Nie udało się wyczyścić Moich notatek. + Moje notatki mają następcę: Encly, aplikację, która trzyma notatki zaszyfrowane i offline. Przenieś wszystko jednym dotknięciem. + Jak przenieść + Później + Szukaj + Opcje widoku + Opcje widoku. Notatki są wyświetlane jako lista + Opcje widoku. Notatki są wyświetlane jako siatka + Kolejność + Ostatnio edytowane na początku + Ostatnio edytowane na końcu + Układ + Lista + Siatka + Tryb czytania + Wróć do edycji + Przypomnienie: %1$s. Zmień przypomnienie + Tryb otwierania notatek + Nowe notatki zawsze otwierają się gotowe do pisania. + Automatycznie + Tryb czytania w prostym edytorze, tryb edycji w rozszerzonym + Czytanie + Nic nie zmieni się przypadkiem. Stuknij Edytuj, aby zacząć pisać + Edycja + Kursor jest na miejscu, a klawiatura się otwiera + Otwieraj notatki + Tam, gdzie skończyłeś + Na wierszu, który czytałeś, z kursorem tam, gdzie skończyłeś edycję + Od początku + Zawsze od pierwszego wiersza + Dwukrotne stuknięcie, aby edytować + W trybie czytania stuknij dwukrotnie tekst, aby zacząć edycję dokładnie w tym miejscu. + Wybrano + + + Własne… + Własne powtarzanie + Powtarzaj co + Wpisz liczbę od 1 do 999 + Gotowe + Własne powtarzanie: %1$s. Zmień + Co godzinę + Co roku + Przypomnienia mogą przychodzić z opóźnieniem. Zezwól na „Alarmy i przypomnienia”, aby przychodziły na czas. + Zezwól + + Co %d godzinę + Co %d godziny + Co %d godzin + Co %d godziny + + + Co %d dzień + Co %d dni + Co %d dni + Co %d dnia + + + Co %d tydzień + Co %d tygodnie + Co %d tygodni + Co %d tygodnia + + + Co %d miesiąc + Co %d miesiące + Co %d miesięcy + Co %d miesiąca + + + Co %d rok + Co %d lata + Co %d lat + Co %d roku + + + %d godzina + %d godziny + %d godzin + %d godziny + + + %d dzień + %d dni + %d dni + %d dnia + + + %d tydzień + %d tygodnie + %d tygodni + %d tygodnia + + + %d miesiąc + %d miesiące + %d miesięcy + %d miesiąca + + + %d rok + %d lata + %d lat + %d roku + + Historia wersji + Wcześniejsze wersje tej notatki są przechowywane tylko na tym urządzeniu, maksymalnie %1$d. Nie są synchronizowane ani dołączane do kopii zapasowych. + Nie ma jeszcze wcześniejszych wersji + Gdy notatka się zmienia, jej wcześniejszy tekst jest zachowywany tutaj, aby można go było przywrócić. + Zapisano automatycznie + Przed synchronizacją + Przed przywróceniem + Przed konfliktem + Przed chwilą, %1$s + Ta wersja + Bieżąca notatka + Ta wersja ma ten sam tekst co bieżąca notatka. + Przywróć + Przywrócić tę wersję? + Bieżący tekst notatki zostanie zastąpiony. Pozostanie w historii wersji, więc możesz do niego wrócić. + Załączniki, których nie ma już w notatce, nie wrócą z tą wersją. + Przywrócono wersję + Nie udało się przywrócić tej wersji + Pusta notatka + Własna kolejność + Naciśnij i przytrzymaj notatkę, a następnie przeciągnij ją w nowe miejsce. Przypięte notatki pozostają nad pozostałymi. + Przenieś wyżej + Przenieś niżej + Już na początku + Już na końcu + Przeniesiono na pozycję %1$d z %2$d + Cofnij + Ponów + Ukryj klawiaturę + Aby zmieniać kolejność przeciąganiem, włącz „Własna kolejność” + Włącz diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index b6ed3eaa..fb92f0c6 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -19,9 +19,8 @@ Открыть контакты разработчика Этот контент не поддерживается. Отсутствует приложение Play Market - Форматировать заметки - Поиск в заметках + Поиск в заметках Закрыть Добавить метку Удалить @@ -104,7 +103,6 @@ Ошибка: Резервная копия повреждена Ошибка сервера, или плохое качество интернета - Сортировать заметки Уважаемый пользователь @@ -173,9 +171,9 @@ Поиск Используйте панель поиска для быстрого нахождения ваших заметок по названию или содержимому Сортировка - Сортируйте ваши заметки по дате последнего изменения или названию в восходящем или нисходящем порядке + Нажмите кнопку параметров просмотра в строке поиска, чтобы показывать недавно изменённые заметки первыми или последними, или выберите «Свой порядок» и расставьте заметки сами, перетаскивая их. Формат отображения - Переключайтесь между списком и сеткой для отображения ваших заметок в удобном для вас макете + В тех же параметрах просмотра переключайтесь между списком и сеткой. Жесты свайп @@ -227,6 +225,8 @@ Теги Нажмите эту кнопку, чтобы создать тег. Опции тега + Изменить тег + Редактировать заметку Создать тег Заметки с этим тегом отображаются в общем списке. Заметки с этим тегом скрыты в общем списке. @@ -373,6 +373,7 @@ Напоминание Завтра %1$s Выберите будущее время + Выбранное время, %1$s, уже прошло. Выберите более позднее время. Новая заметка Все Продолжайте вводить… @@ -454,9 +455,9 @@ Задайте напоминание для любой задачи, нажав значок колокольчика. В указанное время придёт уведомление. Нажмите на него, чтобы сразу открыть задачу. Напоминания Напоминания о заметках - Откройте любую заметку и нажмите значок колокольчика, чтобы задать напоминание. Выберите время быстрым вариантом или через выбор даты и времени. В назначенное время появится уведомление. + Откройте заметку, нажмите «Больше» и выберите «Напоминание». Выберите время быстрым вариантом или через выбор даты и времени. Напоминание появится вверху заметки, нажмите на него, чтобы изменить. В назначенное время появится уведомление. Повторение - При настройке напоминания для заметки включите переключатель «Повтор», чтобы выбрать цикл: ежедневно, еженедельно или ежемесячно. После каждого уведомления напоминание переносится автоматически. + При настройке напоминания для заметки выберите, как оно повторяется: ежедневно, еженедельно, ежемесячно или «Свой…» — каждые несколько часов, дней, недель, месяцев или лет. Каждый повтор отсчитывается от выбранного времени, поэтому напоминание на 31-е число в более коротких месяцах срабатывает в последний день, а затем снова 31-го. Повторяющиеся напоминания сохраняются и после перезапуска телефона. Интервальные уведомления Для повторяющихся напоминаний можно включить повторное уведомление каждые 5, 10, 15, 30 или 60 минут. Уведомление будет повторяться с выбранным интервалом до следующего срабатывания. Звук и громкость уведомлений @@ -481,4 +482,195 @@ После подтверждения первой синхронизации можно включить фоновую. Она запускается примерно каждые 6 часов при наличии интернета, и её можно отключить в любое время. Разрешение конфликтов Когда локальная версия и версия на Drive отличаются, для каждого конфликта выберите, какую сохранить: локальную или из Google Drive. + + + Знакомьтесь: Encly + Преемник «Мои Заметки» + От автора «Мои Заметки» + Ваши заметки — зашифрованы и только на вашем телефоне. Без аккаунта и без интернета. + Всегда зашифровано + Каждая заметка хранится в зашифрованной базе данных, резервные копии тоже зашифрованы. + Офлайн и без аккаунта + У Encly нет разрешения на интернет, поэтому заметки никогда не покидают телефон. + Блокировка PIN-кодом и отпечатком + Открыть заметки можно только вашим PIN-кодом или отпечатком пальца. + Бесплатно и с открытым кодом + Код может проверить любой. Есть в Google Play, F-Droid и на GitHub. + Перенос копирует заметки в Encly. Из «Мои Заметки» ничего не удаляется, а Encly ничего не отправляет обратно. + Переедет в Encly + Заметки, включая корзину + Задачи + Теги + Списки задач + В Encly станут тегами + Останется в «Мои Заметки» + Вложения и изображения + Encly не хранит файлы + Напоминания + В Encly нет напоминаний + Закрепление заметок + Сами заметки переедут, но незакреплёнными + Ничего — всё переедет в Encly. + Получить Encly + Обновить Encly + Перенести мои заметки в Encly + Готовим заметки… + Откроется Encly и заберёт всё за один раз. Это займёт лишь мгновение. + Бесплатно в Google Play, F-Droid и на GitHub. После установки вернитесь сюда. + Эта версия Encly пока не умеет принимать заметки. Обновите её и вернитесь сюда. + Установленное приложение Encly — не официальная сборка, поэтому заметки ему не передаются. Установите Encly из Google Play, F-Droid или GitHub. + Заметки уже в Encly + Заметки + Пропущено + Вложений и изображений осталось в «Мои Заметки»: %1$d. + Перенос не завершился + Перенос отменён. Ничего не изменилось. + Encly принимает заметки только от официального приложения «Мои Заметки». Установите «Мои Заметки» из Google Play, F-Droid или GitHub и попробуйте снова. + Эта версия Encly не может прочитать данные из этой версии «Мои Заметки». Обновите Encly и попробуйте снова. + Encly не смог прочитать данные. Ничего не импортировано. + Данных слишком много, чтобы перенести их за один раз. Ничего не импортировано. + Encly не смог завершить импорт. Из «Мои Заметки» ничего не удалено. + Не удалось подготовить заметки для Encly. + «Мои Заметки» всё ещё хранят собственную копию. Если она больше не нужна, её можно очистить — сначала мы попросим подтверждение. + Очистить «Мои Заметки» + Очистить «Мои Заметки»? + Это навсегда удалит из «Мои Заметки» все заметки (включая корзину), задачи, теги, списки задач и файлы вложений. Если включена синхронизация с Google Диском, они будут удалены и из синхронизированной копии. Вложения не переносились в Encly. Это действие нельзя отменить. + Удалить всё + «Мои Заметки» очищены. + Не удалось очистить «Мои Заметки». + У «Мои Заметки» появился преемник — Encly: заметки, которые всегда зашифрованы и работают офлайн. Перенесите всё одним касанием. + Как перенести + Позже + Поиск + Параметры просмотра + Параметры просмотра. Заметки показаны списком + Параметры просмотра. Заметки показаны сеткой + Порядок + Сначала недавно изменённые + Недавно изменённые в конце + Вид + Список + Сетка + Режим чтения + Вернуться к редактированию + Напоминание: %1$s. Изменить напоминание + Режим открытия заметок + Новые заметки всегда открываются готовыми к набору. + Автоматически + Режим чтения в простом редакторе, режим редактирования в расширенном + Чтение + Ничего не изменится случайно. Нажмите «Редактировать», чтобы начать печатать + Редактирование + Курсор на месте, клавиатура открывается + Открывать заметки + С места, где вы остановились + На строке, которую вы читали, и с курсором там, где вы закончили редактировать + С начала + Всегда с первой строки + Двойное касание для редактирования + В режиме чтения дважды коснитесь текста, чтобы начать редактировать именно там, где коснулись. + Выбрано + + + Свой… + Свой повтор + Повторять каждые + Введите число от 1 до 999 + Готово + Свой повтор: %1$s. Изменить + Ежечасно + Ежегодно + Напоминания могут приходить с опозданием. Разрешите «Будильники и напоминания», чтобы они приходили вовремя. + Разрешить + + Каждый %d час + Каждые %d часа + Каждые %d часов + Каждые %d часа + + + Каждый %d день + Каждые %d дня + Каждые %d дней + Каждые %d дня + + + Каждую %d неделю + Каждые %d недели + Каждые %d недель + Каждые %d недели + + + Каждый %d месяц + Каждые %d месяца + Каждые %d месяцев + Каждые %d месяца + + + Каждый %d год + Каждые %d года + Каждые %d лет + Каждые %d года + + + %d час + %d часа + %d часов + %d часа + + + %d день + %d дня + %d дней + %d дня + + + %d неделя + %d недели + %d недель + %d недели + + + %d месяц + %d месяца + %d месяцев + %d месяца + + + %d год + %d года + %d лет + %d года + + История версий + Предыдущие версии этой заметки хранятся только на этом устройстве, до %1$d. Они не синхронизируются и не попадают в резервные копии. + Предыдущих версий пока нет + Когда заметка меняется, её предыдущий текст сохраняется здесь, чтобы вы могли его вернуть. + Автосохранение + Перед синхронизацией + Перед восстановлением + Перед конфликтом + Только что, %1$s + Эта версия + Текущая заметка + У этой версии тот же текст, что и у текущей заметки. + Восстановить + Восстановить эту версию? + Текущий текст заметки будет заменён. Он останется в истории версий, так что вы сможете к нему вернуться. + Вложения, которых уже нет в заметке, не вернутся с этой версией. + Версия восстановлена + Не удалось восстановить эту версию + Пустая заметка + Свой порядок + Нажмите и удерживайте заметку, затем перетащите её на новое место. Закреплённые заметки остаются над остальными. + Переместить выше + Переместить ниже + Уже в начале + Уже в конце + Перемещено на позицию %1$d из %2$d + Отменить + Повторить + Скрыть клавиатуру + Чтобы менять порядок перетаскиванием, включите «Свой порядок» + Включить diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index 723aee71..d54d659a 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -14,8 +14,7 @@ Відкрити контакти розробника Цей вміст не підтримується. Відсутній Play Market - Форматувати нотатки - Пошук у нотатках + Пошук у нотатках Закрити Додати тег Видалити @@ -100,7 +99,6 @@ Відновлення даних успішно завершено Помилка: Резервна копія пошкоджена Помилка сервера або погана якість інтернету - Сортувати нотатки Шановний користувач Розробник проекту каже вам спасибі за вашу покупку. Ми будемо прагнути стати кращими і зручнішими. \n З повагою, мої нотатки! @@ -171,9 +169,9 @@ Пошук Використовуйте панель пошуку для швидкого знаходження ваших нотаток за назвою або змістом Сортування - Сортуйте ваші нотатки за датою останньої зміни або назвою у висхідному чи спадному порядку + Натисніть кнопку параметрів перегляду в рядку пошуку, щоб показувати нещодавно змінені нотатки першими або останніми, або виберіть «Власний порядок» і розставте нотатки самі, перетягуючи їх. Формат відображення - Переключайтеся між списком та сіткою для відображення ваших нотаток у зручному для вас макеті + У тих самих параметрах перегляду перемикайтеся між списком і сіткою. Жести свайп @@ -249,9 +247,9 @@ Нагадування Нагадування для нотаток - Відкрийте будь-яку нотатку та натисніть іконку дзвіночка, щоб встановити нагадування. Виберіть час за допомогою швидких варіантів або вручну. Сповіщення з\'явиться у вказаний час. + Відкрийте нотатку, натисніть «Більше» й виберіть «Нагадування». Виберіть час за допомогою швидких варіантів або вручну. Нагадування з\'явиться вгорі нотатки, натисніть на нього, щоб змінити. Сповіщення з\'явиться у вказаний час. Параметри повторення - При встановленні нагадування для нотатки увімкніть перемикач «Повторення», щоб вибрати цикл: щодня, щотижня або щомісяця. Нагадування автоматично переноситься після кожного спрацювання. + Під час налаштування нагадування для нотатки виберіть, як воно повторюється: щодня, щотижня, щомісяця або «Власний…» — кожні кілька годин, днів, тижнів, місяців чи років. Кожне повторення відраховується від вибраного часу, тож нагадування на 31-ше число в коротших місяцях спрацює останнього дня, а потім знову 31-го. Повторювані нагадування зберігаються й після перезапуску телефона. Інтервальні сповіщення Для повторюваних нагадувань можна увімкнути інтервальне повторення кожні 5, 10, 15, 30 або 60 хвилин. Сповіщення повторюватиметься через вибраний інтервал до наступного запланованого часу. Звук і гучність сповіщень @@ -260,6 +258,8 @@ Теги Натисніть цю кнопку, щоб створити тег. Налаштування тегу + Змінити тег + Редагувати нотатку Створити тег Нотатки з цим тегом відображаються у загальному списку. Нотатки з цим тегом приховані у загальному списку. @@ -410,6 +410,7 @@ Нагадування Завтра %1$s Оберіть майбутній час + Обраний час, %1$s, уже минув. Оберіть пізніший час. Нова нотатка Усі Продовжуйте вводити… @@ -485,4 +486,195 @@ Синхронізація та копія на Google Диску доступні після входу. Синхронізація недоступна Синхронізація та копія на Google Диску потребують сервісів Google Play, яких немає на цьому пристрої. + + + Знайомтеся: Encly + Наступник «Мої Нотатки» + Від автора «Мої Нотатки» + Ваші нотатки — зашифровані й лише на вашому телефоні. Без акаунта й без інтернету. + Завжди зашифровано + Кожна нотатка зберігається в зашифрованій базі даних, резервні копії теж зашифровані. + Офлайн і без акаунта + Encly не має дозволу на інтернет, тож нотатки ніколи не покидають телефон. + Блокування PIN-кодом і відбитком + Відкрити нотатки можна лише вашим PIN-кодом або відбитком пальця. + Безкоштовний і з відкритим кодом + Код може перевірити будь-хто. Є в Google Play, F-Droid і на GitHub. + Перенесення копіює нотатки в Encly. З «Мої Нотатки» нічого не видаляється, а Encly нічого не надсилає назад. + Переїде в Encly + Нотатки, разом із кошиком + Завдання + Теги + Списки завдань + В Encly стануть тегами + Залишиться в «Мої Нотатки» + Вкладення та зображення + Encly не зберігає файли + Нагадування + В Encly немає нагадувань + Закріплення нотаток + Самі нотатки переїдуть, але незакріпленими + Нічого — усе переїде в Encly. + Отримати Encly + Оновити Encly + Перенести мої нотатки в Encly + Готуємо нотатки… + Відкриється Encly і забере все за один раз. Це займе лише мить. + Безкоштовно в Google Play, F-Droid і на GitHub. Після встановлення поверніться сюди. + Ця версія Encly ще не вміє приймати нотатки. Оновіть її і поверніться сюди. + Встановлений застосунок Encly — не офіційна збірка, тому нотатки йому не передаватимуться. Встановіть Encly з Google Play, F-Droid або GitHub. + Нотатки вже в Encly + Нотатки + Пропущено + Вкладень і зображень залишилося в «Мої Нотатки»: %1$d. + Перенесення не завершилося + Перенесення скасовано. Нічого не змінилося. + Encly приймає нотатки лише від офіційного застосунку «Мої Нотатки». Встановіть «Мої Нотатки» з Google Play, F-Droid або GitHub і спробуйте ще раз. + Ця версія Encly не може прочитати дані з цієї версії «Мої Нотатки». Оновіть Encly і спробуйте ще раз. + Encly не зміг прочитати дані. Нічого не імпортовано. + Даних забагато, щоб перенести їх за один раз. Нічого не імпортовано. + Encly не зміг завершити імпорт. З «Мої Нотатки» нічого не видалено. + Не вдалося підготувати нотатки для Encly. + «Мої Нотатки» досі зберігають власну копію. Якщо вона більше не потрібна, її можна очистити — спершу ми попросимо підтвердження. + Очистити «Мої Нотатки» + Очистити «Мої Нотатки»? + Це назавжди видалить з «Мої Нотатки» всі нотатки (разом із кошиком), завдання, теги, списки завдань і файли вкладень. Якщо увімкнено синхронізацію з Google Диском, їх буде видалено й із синхронізованої копії. Вкладення не переносилися в Encly. Цю дію не можна скасувати. + Видалити все + «Мої Нотатки» очищено. + Не вдалося очистити «Мої Нотатки». + У «Мої Нотатки» з\'явився наступник — Encly: нотатки, які завжди зашифровані й працюють офлайн. Перенесіть усе одним дотиком. + Як перенести + Пізніше + Пошук + Параметри перегляду + Параметри перегляду. Нотатки показано списком + Параметри перегляду. Нотатки показано сіткою + Порядок + Спочатку нещодавно змінені + Нещодавно змінені в кінці + Вигляд + Список + Сітка + Режим читання + Повернутися до редагування + Нагадування: %1$s. Змінити нагадування + Режим відкриття нотаток + Нові нотатки завжди відкриваються готовими до набору. + Автоматично + Режим читання у простому редакторі, режим редагування в розширеному + Читання + Нічого не зміниться випадково. Натисніть «Редагувати», щоб почати друкувати + Редагування + Курсор на місці, клавіатура відкривається + Відкривати нотатки + З місця, де ви зупинилися + На рядку, який ви читали, і з курсором там, де ви закінчили редагувати + З початку + Завжди з першого рядка + Подвійний дотик для редагування + У режимі читання двічі торкніться тексту, щоб почати редагувати саме там, де торкнулися. + Вибрано + + + Власний… + Власне повторення + Повторювати кожні + Введіть число від 1 до 999 + Готово + Власне повторення: %1$s. Змінити + Щогодини + Щороку + Нагадування можуть спрацьовувати із запізненням. Дозвольте «Будильники й нагадування», щоб вони приходили вчасно. + Дозволити + + Кожну %d годину + Кожні %d години + Кожні %d годин + Кожні %d години + + + Кожен %d день + Кожні %d дні + Кожні %d днів + Кожні %d дня + + + Кожен %d тиждень + Кожні %d тижні + Кожні %d тижнів + Кожні %d тижня + + + Кожен %d місяць + Кожні %d місяці + Кожні %d місяців + Кожні %d місяця + + + Кожен %d рік + Кожні %d роки + Кожні %d років + Кожні %d року + + + %d година + %d години + %d годин + %d години + + + %d день + %d дні + %d днів + %d дня + + + %d тиждень + %d тижні + %d тижнів + %d тижня + + + %d місяць + %d місяці + %d місяців + %d місяця + + + %d рік + %d роки + %d років + %d року + + Історія версій + Попередні версії цієї нотатки зберігаються лише на цьому пристрої, до %1$d. Вони не синхронізуються й не потрапляють до резервних копій. + Попередніх версій ще немає + Коли нотатка змінюється, її попередній текст зберігається тут, щоб ви могли його повернути. + Автозбереження + Перед синхронізацією + Перед відновленням + Перед конфліктом + Щойно, %1$s + Ця версія + Поточна нотатка + Ця версія має той самий текст, що й поточна нотатка. + Відновити + Відновити цю версію? + Поточний текст нотатки буде замінено. Він залишиться в історії версій, тож ви зможете до нього повернутися. + Вкладення, яких уже немає в нотатці, не повернуться з цією версією. + Версію відновлено + Не вдалося відновити цю версію + Порожня нотатка + Власний порядок + Натисніть і утримуйте нотатку, а потім перетягніть її на нове місце. Закріплені нотатки залишаються над іншими. + Перемістити вище + Перемістити нижче + Уже на початку + Уже в кінці + Переміщено на позицію %1$d з %2$d + Скасувати + Повторити + Сховати клавіатуру + Щоб змінити порядок перетягуванням, увімкніть «Власний порядок» + Увімкнути diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml index d47003df..a47efc8b 100644 --- a/app/src/main/res/values/dimens.xml +++ b/app/src/main/res/values/dimens.xml @@ -11,7 +11,13 @@ 10dp 16dp + + 48dp + 1dp + 4dp 10dp + + 20dp \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 7188e8a8..7d18644f 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -12,10 +12,9 @@ To exit, you need to repeat the action Missing Play Market app This content is not supported. - Format notes - Search notes + Search notes Close Add Tag Delete @@ -72,7 +71,6 @@ My Notes "@"pasichDev pasichDev@outlook.com - EditNote Andrii Pasichnik (pasichDev) Thanks byy Slider edit text size @@ -117,7 +115,6 @@ Data restore completed successfully Error: Backup corrupted Server error or poor internet quality - Sort notes Dear User @@ -188,9 +185,9 @@ Search Use the search bar to quickly find your notes by title or content Sorting - Sort your notes by last modified date or name in ascending or descending order + Tap the view options button in the search bar to show recently edited notes first or last, or choose Custom order and arrange notes yourself by dragging them. Display Format - Switch between list and grid to display your notes in a layout convenient for you + In the same view options, switch between a list and a grid. Swipe Gestures @@ -253,9 +250,9 @@ Reminders Note Reminders - Open any note and tap the bell icon to set a reminder. Pick a time using quick options or the date and time picker. A notification will appear at the scheduled time. + Open a note, tap More and choose Reminder. Pick a time using quick options or the date and time picker. The reminder appears at the top of the note, tap it to change it. A notification will appear at the scheduled time. Repeat Options - When setting a note reminder, enable the Repeat toggle to choose a repeat cycle: Daily, Weekly, or Monthly. The reminder reschedules automatically after each notification. + When setting a note reminder, choose how it repeats: Daily, Weekly, Monthly, or Custom… for every few hours, days, weeks, months or years. Each repeat is counted from the time you picked, so a reminder on the 31st rings on the last day of shorter months and is back on the 31st after them. Repeating reminders survive a restart of the phone. Interval Notifications For repeating reminders, you can enable interval re-notifications every 5, 10, 15, 30, or 60 minutes. The notification will repeat at the chosen interval until the next scheduled occurrence. Notification Sound and Volume @@ -282,6 +279,8 @@ Press this button to create tag. Tags Tag options + Change tag + Edit note Create Tag Notes marked with this tag are displayed in the general list. Notes marked with this tag are hidden in the general list. @@ -467,6 +466,7 @@ Reminder Tomorrow %1$s Please select a future time + The time you chose, %1$s, has already passed. Choose a later time. New note All Keep typing… @@ -513,4 +513,176 @@ Sync and Google Drive backup become available after you sign in. Sync unavailable Sync and Google Drive backup need Google Play services, which this device does not have. + + + Meet Encly + The successor of My Notes + From the author of My Notes + Notes that stay encrypted and stay on your phone. No account, no internet. + Encrypted, always + Every note is kept in an encrypted database, and backups are encrypted too. + Offline, no account + Encly has no internet permission, so your notes never leave the phone. + PIN and fingerprint lock + Only your PIN or fingerprint opens your notes. + Free and open source + Anyone can check the code. Available on Google Play, F-Droid and GitHub. + Moving copies your notes to Encly. Nothing is deleted from My Notes, and Encly never sends anything back. + Moves to Encly + Notes, including the trash + Tasks + Tags + Task lists + Become tags in Encly + Stays in My Notes + Attachments and images + Encly does not store files + Reminders + Encly has no reminders + Pins on notes + The notes themselves move, unpinned + Nothing. Everything you have moves to Encly. + Get Encly + Update Encly + Move my notes to Encly + Preparing your notes… + Encly opens and takes everything in one go. It only takes a moment. + Free on Google Play, F-Droid and GitHub. Come back here once it is installed. + This version of Encly cannot receive notes yet. Update it, then come back here. + The installed app called Encly is not the official build, so your notes will not be sent to it. Install Encly from Google Play, F-Droid or GitHub. + Your notes are in Encly + Notes + Skipped + Attachments and images left in My Notes: %1$d. + The move did not finish + The move was cancelled. Nothing has changed. + Encly accepts notes only from the official My Notes app. Install My Notes from Google Play, F-Droid or GitHub and try again. + This Encly cannot read data from this version of My Notes. Update Encly and try again. + Encly could not read the data. Nothing was imported. + There is too much data to move in one go. Nothing was imported. + Encly could not finish the import. Nothing was deleted from My Notes. + Could not prepare your notes for Encly. + My Notes still keeps its own copy. If you no longer need it, you can clear it. You will be asked to confirm first. + Clear My Notes + Clear My Notes? + This permanently deletes all notes (including the trash), tasks, tags, task lists and attachment files from My Notes. If Google Drive sync is on, they are deleted from the synced copy too. Attachments did not move to Encly. This cannot be undone. + Delete everything + My Notes has been cleared. + Could not clear My Notes. + My Notes has a successor: Encly, a notes app that keeps everything encrypted and offline. Move all your notes in one tap. + See how to move + Later + Search + View options + View options. Notes are shown as a list + View options. Notes are shown as a grid + Order + Recently edited first + Recently edited last + Layout + List + Grid + Reading mode + Back to editing + Reminder: %1$s. Change reminder + Open notes in + New notes always open ready for typing. + Automatic + Reading mode in the simple editor, editing mode in the extended editor + Reading mode + Nothing changes by accident. Tap Edit to start typing + Editing mode + The cursor is in place and the keyboard opens + Open notes at + Where you left off + The line you were reading, and the cursor where you stopped editing + The beginning + Always start from the first line + Double-tap to edit + In reading mode, double-tap the text to start editing right where you tapped. + Selected + + + Custom… + Custom repeat + Repeat every + Enter a number from 1 to 999 + Done + Custom repeat: %1$s. Change + Every hour + Every year + Reminders may ring late. Allow Alarms & reminders to get them on time. + Allow + %1$s · %2$s + + Every %d hour + Every %d hours + + + Every %d day + Every %d days + + + Every %d week + Every %d weeks + + + Every %d month + Every %d months + + + Every %d year + Every %d years + + + %d hour + %d hours + + + %d day + %d days + + + %d week + %d weeks + + + %d month + %d months + + + %d year + %d years + + Version history + Earlier versions of this note are kept on this device only, up to %1$d. They are not synced or included in backups. + No earlier versions yet + When this note changes, its earlier text is kept here so you can bring it back. + Autosaved + Before sync + Before restore + Before conflict + Just now, %1$s + This version + Current note + This version has the same text as the current note. + Restore + Restore this version? + The note\'s current text will be replaced. It stays in version history, so you can switch back. + Attachments that are no longer in the note won\'t come back with this version. + Version restored + Couldn\'t restore this version + Empty note + Custom order + Press and hold a note, then drag it to a new place. Pinned notes stay above the others. + Move up + Move down + Already at the top + Already at the bottom + Moved to position %1$d of %2$d + Undo + Redo + Hide keyboard + To reorder notes by dragging, turn on “Custom order” + Turn on diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml index a8ca6cdc..2f4d2279 100644 --- a/app/src/main/res/values/styles.xml +++ b/app/src/main/res/values/styles.xml @@ -1,5 +1,5 @@ - + + + + - \ No newline at end of file + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/xml/file_paths.xml b/app/src/main/res/xml/file_paths.xml index 07932b87..6787d375 100644 --- a/app/src/main/res/xml/file_paths.xml +++ b/app/src/main/res/xml/file_paths.xml @@ -3,6 +3,10 @@ + + diff --git a/app/src/test/java/com/pasich/mynotes/cache/AppPreferencesCacheSortTest.java b/app/src/test/java/com/pasich/mynotes/cache/AppPreferencesCacheSortTest.java new file mode 100644 index 00000000..824b835c --- /dev/null +++ b/app/src/test/java/com/pasich/mynotes/cache/AppPreferencesCacheSortTest.java @@ -0,0 +1,89 @@ +package com.pasich.mynotes.cache; + +import static com.google.common.truth.Truth.assertThat; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.pasich.mynotes.data.preferences.SafePreferences; +import com.pasich.mynotes.utils.constants.settings.PreferencesConfig; +import com.pasich.mynotes.utils.constants.settings.SortParam; +import org.junit.Before; +import org.junit.Test; +import org.mockito.Mockito; + +/** The custom order is a device-local choice beside the synced date order. */ +public class AppPreferencesCacheSortTest { + + private SafePreferences preferences; + private AppPreferencesCache cache; + + @Before + public void setUp() { + preferences = Mockito.mock(SafePreferences.class); + when(preferences.getString( + PreferencesConfig.ARGUMENT_PREFERENCE_SORT, + PreferencesConfig.ARGUMENT_DEFAULT_SORT_PREF)) + .thenReturn(SortParam.DataReserve); + when(preferences.getString( + eq(PreferencesConfig.ARGUMENT_PREFERENCE_LAST_KNOWN_VERSION), anyString())) + .thenReturn("0"); + cache = new AppPreferencesCache(preferences); + cache.initialize(); + } + + @Test + public void choosingCustom_keepsTheSyncedDateOrderAsItWas() { + cache.setSortPref(SortParam.Custom); + + assertThat(cache.getSortPref()).isEqualTo(SortParam.Custom); + // What is backed up and synced stays a value every version of the app understands. + assertThat(cache.getSyncedSortPref()).isEqualTo(SortParam.DataReserve); + verify(preferences).putBoolean(PreferencesConfig.ARGUMENT_PREFERENCE_CUSTOM_ORDER, true); + verify(preferences, never()) + .putString(PreferencesConfig.ARGUMENT_PREFERENCE_SORT, SortParam.Custom); + } + + @Test + public void choosingADateOrder_leavesCustom() { + cache.setSortPref(SortParam.Custom); + cache.setSortPref(SortParam.DataSort); + + assertThat(cache.getSortPref()).isEqualTo(SortParam.DataSort); + assertThat(cache.getSyncedSortPref()).isEqualTo(SortParam.DataSort); + verify(preferences).putBoolean(PreferencesConfig.ARGUMENT_PREFERENCE_CUSTOM_ORDER, false); + } + + @Test + public void theCustomChoiceSurvivesARestart() { + when(preferences.getBoolean(PreferencesConfig.ARGUMENT_PREFERENCE_CUSTOM_ORDER, false)) + .thenReturn(true); + + cache.refresh(); + + assertThat(cache.getSortPref()).isEqualTo(SortParam.Custom); + assertThat(cache.getSyncedSortPref()).isEqualTo(SortParam.DataReserve); + } + + @Test + public void customOrderHint_isDueOnlyOnceAndOnlyOutsideTheCustomOrder() { + assertThat(AppPreferencesCache.customOrderHintDue(false, false, false, false)).isTrue(); + assertThat(AppPreferencesCache.customOrderHintDue(true, false, false, false)).isFalse(); + assertThat(AppPreferencesCache.customOrderHintDue(false, true, false, false)).isFalse(); + assertThat(AppPreferencesCache.customOrderHintDue(false, false, true, false)).isFalse(); + + assertThat(cache.isCustomOrderHintShown()).isFalse(); + cache.setCustomOrderHintShown(); + verify(preferences) + .putBoolean(PreferencesConfig.ARGUMENT_PREFERENCE_CUSTOM_ORDER_HINT_SHOWN, true); + when(preferences.getBoolean( + PreferencesConfig.ARGUMENT_PREFERENCE_CUSTOM_ORDER_HINT_SHOWN, false)) + .thenReturn(true); + assertThat( + AppPreferencesCache.customOrderHintDue( + false, false, false, cache.isCustomOrderHintShown())) + .isFalse(); + } +} diff --git a/app/src/test/java/com/pasich/mynotes/cache/NoteOpeningPreferencesTest.java b/app/src/test/java/com/pasich/mynotes/cache/NoteOpeningPreferencesTest.java new file mode 100644 index 00000000..26db53a8 --- /dev/null +++ b/app/src/test/java/com/pasich/mynotes/cache/NoteOpeningPreferencesTest.java @@ -0,0 +1,95 @@ +package com.pasich.mynotes.cache; + +import static com.google.common.truth.Truth.assertThat; + +import android.content.Context; +import com.pasich.mynotes.cache.NoteOpeningPreferences.OpenMode; +import com.pasich.mynotes.cache.NoteOpeningPreferences.OpenPosition; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.RuntimeEnvironment; +import org.robolectric.annotation.Config; + +@RunWith(RobolectricTestRunner.class) +@Config(sdk = 35) +public class NoteOpeningPreferencesTest { + + private Context context; + private NoteOpeningPreferences prefs; + + @Before + public void setUp() { + context = RuntimeEnvironment.getApplication(); + prefs = new NoteOpeningPreferences(context); + } + + @Test + public void defaults_keepTodaysBehaviour() { + assertThat(prefs.getOpenMode()).isEqualTo(OpenMode.AUTO); + assertThat(prefs.getOpenPosition()).isEqualTo(OpenPosition.LAST); + assertThat(prefs.restoresLastPosition()).isTrue(); + assertThat(prefs.isDoubleTapToEdit()).isFalse(); + } + + @Test + public void choicesSurviveANewInstance() { + prefs.setOpenMode(OpenMode.READ); + prefs.setOpenPosition(OpenPosition.START); + prefs.setDoubleTapToEdit(true); + + NoteOpeningPreferences reread = new NoteOpeningPreferences(context); + + assertThat(reread.getOpenMode()).isEqualTo(OpenMode.READ); + assertThat(reread.getOpenPosition()).isEqualTo(OpenPosition.START); + assertThat(reread.restoresLastPosition()).isFalse(); + assertThat(reread.isDoubleTapToEdit()).isTrue(); + } + + @Test + public void unknownStoredValues_fallBackToTheDefaults() { + context.getSharedPreferences(NoteOpeningPreferences.FILE, Context.MODE_PRIVATE) + .edit() + .putString(NoteOpeningPreferences.KEY_MODE, "SPLIT_VIEW") + .putString(NoteOpeningPreferences.KEY_POSITION, "MIDDLE") + .commit(); + + assertThat(prefs.getOpenMode()).isEqualTo(OpenMode.AUTO); + assertThat(prefs.getOpenPosition()).isEqualTo(OpenPosition.LAST); + } + + @Test + public void keptOutOfTheSyncedSettingsFile() { + prefs.setOpenMode(OpenMode.EDIT); + + assertThat( + context.getSharedPreferences( + "com.pasich.mynotes_preferences", Context.MODE_PRIVATE) + .getAll() + .containsKey(NoteOpeningPreferences.KEY_MODE)) + .isFalse(); + } + + @Test + public void auto_readsInTheSimpleEditor_editsInTheExtendedOne() { + assertThat(NoteOpeningPreferences.opensInEditMode(OpenMode.AUTO, false, false)).isFalse(); + assertThat(NoteOpeningPreferences.opensInEditMode(OpenMode.AUTO, true, false)).isTrue(); + } + + @Test + public void readAndEdit_applyToBothEditors() { + assertThat(NoteOpeningPreferences.opensInEditMode(OpenMode.READ, false, false)).isFalse(); + assertThat(NoteOpeningPreferences.opensInEditMode(OpenMode.READ, true, false)).isFalse(); + assertThat(NoteOpeningPreferences.opensInEditMode(OpenMode.EDIT, false, false)).isTrue(); + assertThat(NoteOpeningPreferences.opensInEditMode(OpenMode.EDIT, true, false)).isTrue(); + } + + @Test + public void newNotes_alwaysOpenForEditing() { + for (OpenMode mode : OpenMode.values()) { + assertThat(NoteOpeningPreferences.opensInEditMode(mode, false, true)).isTrue(); + assertThat(NoteOpeningPreferences.opensInEditMode(mode, true, true)).isTrue(); + } + } +} diff --git a/app/src/test/java/com/pasich/mynotes/data/history/FakeNoteVersionDao.java b/app/src/test/java/com/pasich/mynotes/data/history/FakeNoteVersionDao.java new file mode 100644 index 00000000..863fbfce --- /dev/null +++ b/app/src/test/java/com/pasich/mynotes/data/history/FakeNoteVersionDao.java @@ -0,0 +1,81 @@ +package com.pasich.mynotes.data.history; + +import com.pasich.mynotes.data.database.dao.NoteVersionDao; +import com.pasich.mynotes.data.database.entities.NoteVersionEntity; +import io.reactivex.Flowable; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +/** In-memory {@link NoteVersionDao} with the same ordering and pruning rules as the SQL. */ +public final class FakeNoteVersionDao implements NoteVersionDao { + + public final List rows = new ArrayList<>(); + + /** Ids of the notes that exist, for {@link #deleteOrphans()}. */ + public final Set liveNoteIds = new HashSet<>(); + + private long nextId = 1; + + private static final Comparator NEWEST_FIRST = + Comparator.comparingLong(v -> v.createdAt) + .thenComparingLong(v -> v.id) + .reversed(); + + @Override + public long insert(NoteVersionEntity version) { + version.id = nextId++; + rows.add(version); + return version.id; + } + + @Override + public NoteVersionEntity getLatest(int noteId) { + List list = getForNoteSync(noteId); + return list.isEmpty() ? null : list.get(0); + } + + @Override + public NoteVersionEntity getById(long id) { + for (NoteVersionEntity row : rows) if (row.id == id) return row; + return null; + } + + @Override + public Flowable> observeForNote(int noteId) { + return Flowable.just(getForNoteSync(noteId)); + } + + @Override + public List getForNoteSync(int noteId) { + List list = new ArrayList<>(); + for (NoteVersionEntity row : rows) if (row.noteLocalId == noteId) list.add(row); + list.sort(NEWEST_FIRST); + return list; + } + + @Override + public void prune(int noteId, int keep) { + List list = getForNoteSync(noteId); + if (list.size() > keep) rows.removeAll(list.subList(keep, list.size())); + } + + @Override + public void deleteForNotes(List noteIds) { + rows.removeIf(row -> noteIds.contains(row.noteLocalId)); + } + + @Override + public void deleteAll() { + rows.clear(); + } + + @Override + public int deleteOrphans() { + int before = rows.size(); + rows.removeIf(row -> !liveNoteIds.contains(row.noteLocalId)); + return before - rows.size(); + } +} diff --git a/app/src/test/java/com/pasich/mynotes/data/history/NoteVersionRestoreTest.java b/app/src/test/java/com/pasich/mynotes/data/history/NoteVersionRestoreTest.java new file mode 100644 index 00000000..8299fdb3 --- /dev/null +++ b/app/src/test/java/com/pasich/mynotes/data/history/NoteVersionRestoreTest.java @@ -0,0 +1,100 @@ +package com.pasich.mynotes.data.history; + +import static com.google.common.truth.Truth.assertThat; + +import com.pasich.mynotes.data.database.entities.NoteVersionEntity; +import com.pasich.mynotes.data.model.Note; +import com.pasich.mynotes.extendedEditor.attach.EditorAttachmentBlocks; +import org.junit.Test; + +public class NoteVersionRestoreTest { + + private static final String KEPT = "editorjs://attachments/note_5/kept.png"; + private static final String GONE = "editorjs://attachments/note_5/gone.png"; + + @Test + public void bringsBackTheTextAndOnlyTheAttachmentsTheNoteStillHas() { + Note current = note("Trip", "now", blocks(KEPT), "[{\"url\":\"" + KEPT + "\"}]"); + NoteVersionEntity version = + version( + "Trip plan", + "then", + "[" + paragraph("then") + "," + image(KEPT) + "," + image(GONE) + "]", + "[{\"url\":\"" + KEPT + "\"},{\"url\":\"" + GONE + "\"}]"); + + NoteVersionRestore plan = NoteVersionRestore.plan(current, version); + + assertThat(plan.title).isEqualTo("Trip plan"); + assertThat(plan.value).isEqualTo("then"); + assertThat(plan.droppedAttachments).isEqualTo(1); + // The gone file may already be deleted from the device; a block for it would be broken. + assertThat(EditorAttachmentBlocks.fileUrls(plan.valueJson)).containsExactly(KEPT); + assertThat(plan.attachments).contains(KEPT); + assertThat(plan.attachments).doesNotContain(GONE); + } + + @Test + public void aPlainTextVersionLeavesNoAttachmentsListed() { + Note current = note("Trip", "now", blocks(KEPT), "[{\"url\":\"" + KEPT + "\"}]"); + NoteVersionEntity version = version("Trip", "plain", "", null); + + NoteVersionRestore plan = NoteVersionRestore.plan(current, version); + + assertThat(plan.valueJson).isEmpty(); + assertThat(plan.attachments).isEqualTo("[]"); + assertThat(plan.droppedAttachments).isEqualTo(0); + } + + @Test + public void applyTo_keepsTagReminderAndPin() { + Note current = note("A", "now", "", "[]"); + current.setTag("work"); + current.setPinned(true); + current.setReminderTime(5_000L); + NoteVersionRestore.plan(current, version("A", "then", "", "[]")).applyTo(current); + + assertThat(current.getValue()).isEqualTo("then"); + assertThat(current.getTag()).isEqualTo("work"); + assertThat(current.isPinned()).isTrue(); + assertThat(current.getReminderTime()).isEqualTo(5_000L); + } + + @Test + public void isLargeEdit_countsWhatIsTakenOutNotWhereTypingHappens() { + String text = "x".repeat(NoteHistory.LARGE_EDIT_CHARS * 2); + assertThat(NoteHistory.isLargeEdit(note("", text, "", ""), note("", text + "y", "", ""))) + .isFalse(); + assertThat(NoteHistory.isLargeEdit(note("", text, "", ""), note("", "y", "", ""))).isTrue(); + assertThat(NoteHistory.isLargeEdit(note("T", "short", "", ""), note("", "", "", ""))) + .isTrue(); + } + + private static String blocks(String url) { + return "[" + paragraph("now") + "," + image(url) + "]"; + } + + private static String paragraph(String text) { + return "{\"id\":\"p\",\"type\":\"paragraph\",\"data\":{\"text\":\"" + text + "\"}}"; + } + + private static String image(String url) { + return "{\"id\":\"" + + url.hashCode() + + "\",\"type\":\"image\",\"data\":{\"file\":{\"url\":\"" + + url + + "\"}}}"; + } + + private static Note note(String title, String value, String valueJson, String attachments) { + Note note = new Note().create(title, value, 10L, ""); + note.setId(5); + note.setValueJson(valueJson); + note.setAttachments(attachments); + return note; + } + + private static NoteVersionEntity version( + String title, String value, String valueJson, String attachments) { + return new NoteVersionEntity(5, null, title, value, valueJson, attachments, 1L, "AUTOSAVE"); + } +} diff --git a/app/src/test/java/com/pasich/mynotes/data/model/RepeatRuleTest.java b/app/src/test/java/com/pasich/mynotes/data/model/RepeatRuleTest.java new file mode 100644 index 00000000..0fbea707 --- /dev/null +++ b/app/src/test/java/com/pasich/mynotes/data/model/RepeatRuleTest.java @@ -0,0 +1,336 @@ +package com.pasich.mynotes.data.model; + +import static com.google.common.truth.Truth.assertThat; + +import java.time.Duration; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.util.ArrayList; +import java.util.List; +import org.junit.Test; + +public class RepeatRuleTest { + + /** Kyiv moves clocks forward on 30 Mar 2025 at 03:00 and back on 26 Oct 2025 at 04:00. */ + private static final ZoneId KYIV = ZoneId.of("Europe/Kyiv"); + + private static final ZoneId UTC = ZoneId.of("UTC"); + + private static long at(ZoneId zone, int y, int mo, int d, int h, int mi) { + return ZonedDateTime.of(y, mo, d, h, mi, 0, 0, zone).toInstant().toEpochMilli(); + } + + private static LocalDateTime local(long millis, ZoneId zone) { + return java.time.Instant.ofEpochMilli(millis).atZone(zone).toLocalDateTime(); + } + + // --- parse ----------------------------------------------------------------------------- + + @Test + public void parse_legacyValues() { + assertThat(RepeatRule.parse("DAILY")) + .isEqualTo(RepeatRule.every(1, RepeatRule.Unit.DAYS, null)); + assertThat(RepeatRule.parse("WEEKLY")) + .isEqualTo(RepeatRule.every(1, RepeatRule.Unit.WEEKS, null)); + assertThat(RepeatRule.parse("MONTHLY")) + .isEqualTo(RepeatRule.every(1, RepeatRule.Unit.MONTHS, null)); + assertThat(RepeatRule.parse("NONE")).isEqualTo(RepeatRule.NONE); + } + + @Test + public void parse_customRuleWithAnchor() { + RepeatRule rule = RepeatRule.parse("EVERY:3:HOURS@1767225600000"); + assertThat(rule.isRepeating()).isTrue(); + assertThat(rule.getInterval()).isEqualTo(3); + assertThat(rule.getUnit()).isEqualTo(RepeatRule.Unit.HOURS); + assertThat(rule.getAnchor()).isEqualTo(1767225600000L); + } + + @Test + public void parse_customRuleWithoutAnchor_usesReminderTime() { + RepeatRule rule = RepeatRule.parse("EVERY:2:YEARS"); + assertThat(rule.getUnit()).isEqualTo(RepeatRule.Unit.YEARS); + assertThat(rule.getAnchor()).isNull(); + assertThat(rule.anchorOr(42L)).isEqualTo(42L); + } + + @Test + public void parse_garbage_isNone() { + String[] garbage = { + null, + "", + "GARBAGE", + "daily", + "EVERY:", + "EVERY:3", + "EVERY::HOURS", + "EVERY:x:DAYS", + "EVERY:0:DAYS", + "EVERY:-1:DAYS", + "EVERY:1000:DAYS", + "EVERY:3:FORTNIGHTS", + "EVERY:3:hours", + "EVERY:3:HOURS@", + "EVERY:3:HOURS@soon", + "every:3:HOURS", + }; + for (String value : garbage) { + assertThat(RepeatRule.parse(value)).isEqualTo(RepeatRule.NONE); + assertThat(RepeatRule.parse(value).isRepeating()).isFalse(); + } + } + + // --- serialize ------------------------------------------------------------------------- + + @Test + public void serialize_everyUnitRoundTrips() { + long anchor = at(UTC, 2026, 1, 31, 9, 0); + for (RepeatRule.Unit unit : RepeatRule.Unit.values()) { + for (int n : new int[] {1, 2, 7, 999}) { + RepeatRule rule = RepeatRule.every(n, unit, anchor); + RepeatRule back = RepeatRule.parse(rule.serialize(UTC)); + assertThat(back.getInterval()).isEqualTo(n); + assertThat(back.getUnit()).isEqualTo(unit); + // The legacy form drops the anchor; the reminder's own time stands in for it. + assertThat(back.anchorOr(anchor)).isEqualTo(anchor); + } + } + } + + @Test + public void serialize_writesLegacyValuesWhereOlderVersionsUnderstandThem() { + long the15th = at(UTC, 2026, 1, 15, 9, 0); + assertThat(RepeatRule.every(1, RepeatRule.Unit.DAYS, the15th).serialize(UTC)) + .isEqualTo("DAILY"); + assertThat(RepeatRule.every(1, RepeatRule.Unit.WEEKS, the15th).serialize(UTC)) + .isEqualTo("WEEKLY"); + assertThat(RepeatRule.every(1, RepeatRule.Unit.MONTHS, the15th).serialize(UTC)) + .isEqualTo("MONTHLY"); + assertThat(RepeatRule.NONE.serialize(UTC)).isEqualTo("NONE"); + } + + @Test + public void serialize_keepsTheAnchorWhenLegacyWouldLoseIt() { + long the31st = at(UTC, 2026, 1, 31, 9, 0); + assertThat(RepeatRule.every(1, RepeatRule.Unit.MONTHS, the31st).serialize(UTC)) + .isEqualTo("EVERY:1:MONTHS@" + the31st); + assertThat(RepeatRule.every(1, RepeatRule.Unit.HOURS, the31st).serialize(UTC)) + .isEqualTo("EVERY:1:HOURS@" + the31st); + assertThat(RepeatRule.every(1, RepeatRule.Unit.YEARS, the31st).serialize(UTC)) + .isEqualTo("EVERY:1:YEARS@" + the31st); + assertThat(RepeatRule.every(3, RepeatRule.Unit.DAYS, the31st).serialize(UTC)) + .isEqualTo("EVERY:3:DAYS@" + the31st); + } + + @Test + public void customRule_isOneTimeForOlderVersions() { + String stored = RepeatRule.every(3, RepeatRule.Unit.HOURS, 1L).serialize(UTC); + assertThat(ReminderRepeat.from(stored)).isEqualTo(ReminderRepeat.NONE); + } + + @Test + public void every_clampsTheInterval() { + assertThat(RepeatRule.every(0, RepeatRule.Unit.DAYS, null).getInterval()).isEqualTo(1); + assertThat(RepeatRule.every(5000, RepeatRule.Unit.DAYS, null).getInterval()).isEqualTo(999); + } + + // --- next ------------------------------------------------------------------------------ + + @Test + public void next_noneDoesNotRepeat() { + assertThat(RepeatRule.NONE.next(0L, 10L, UTC)).isEqualTo(-1L); + } + + @Test + public void next_anchorStillAhead_isTheAnchor() { + RepeatRule rule = RepeatRule.every(2, RepeatRule.Unit.DAYS, null); + assertThat(rule.next(1_000L, 10L, UTC)).isEqualTo(1_000L); + } + + @Test + public void next_isStrictlyAfter() { + long anchor = at(UTC, 2026, 3, 1, 9, 0); + RepeatRule rule = RepeatRule.every(3, RepeatRule.Unit.HOURS, anchor); + assertThat(rule.next(anchor, anchor, UTC)) + .isEqualTo(anchor + Duration.ofHours(3).toMillis()); + } + + @Test + public void next_everyUnitStepsFromTheAnchor() { + long anchor = at(UTC, 2026, 1, 10, 9, 0); + assertThat( + local( + RepeatRule.every(5, RepeatRule.Unit.HOURS, anchor) + .next(anchor, anchor, UTC), + UTC)) + .isEqualTo(LocalDateTime.of(2026, 1, 10, 14, 0)); + assertThat( + local( + RepeatRule.every(2, RepeatRule.Unit.DAYS, anchor) + .next(anchor, anchor, UTC), + UTC)) + .isEqualTo(LocalDateTime.of(2026, 1, 12, 9, 0)); + assertThat( + local( + RepeatRule.every(3, RepeatRule.Unit.WEEKS, anchor) + .next(anchor, anchor, UTC), + UTC)) + .isEqualTo(LocalDateTime.of(2026, 1, 31, 9, 0)); + assertThat( + local( + RepeatRule.every(4, RepeatRule.Unit.MONTHS, anchor) + .next(anchor, anchor, UTC), + UTC)) + .isEqualTo(LocalDateTime.of(2026, 5, 10, 9, 0)); + assertThat( + local( + RepeatRule.every(2, RepeatRule.Unit.YEARS, anchor) + .next(anchor, anchor, UTC), + UTC)) + .isEqualTo(LocalDateTime.of(2028, 1, 10, 9, 0)); + } + + @Test + public void next_monthlyFromThe31st_returnsToThe31st() { + long anchor = at(KYIV, 2025, 1, 31, 9, 0); + RepeatRule rule = RepeatRule.every(1, RepeatRule.Unit.MONTHS, anchor); + List dates = new ArrayList<>(); + long t = anchor; + for (int i = 0; i < 13; i++) { + t = rule.next(anchor, t, KYIV); + dates.add(local(t, KYIV).toLocalDate()); + assertThat(local(t, KYIV).toLocalTime()).isEqualTo(java.time.LocalTime.of(9, 0)); + } + assertThat(dates) + .containsExactly( + LocalDate.of(2025, 2, 28), + LocalDate.of(2025, 3, 31), + LocalDate.of(2025, 4, 30), + LocalDate.of(2025, 5, 31), + LocalDate.of(2025, 6, 30), + LocalDate.of(2025, 7, 31), + LocalDate.of(2025, 8, 31), + LocalDate.of(2025, 9, 30), + LocalDate.of(2025, 10, 31), + LocalDate.of(2025, 11, 30), + LocalDate.of(2025, 12, 31), + LocalDate.of(2026, 1, 31), + LocalDate.of(2026, 2, 28)) + .inOrder(); + } + + @Test + public void next_monthlyFromThe31st_inALeapYear_landsOnThe29th() { + long anchor = at(UTC, 2024, 1, 31, 9, 0); + RepeatRule rule = RepeatRule.every(1, RepeatRule.Unit.MONTHS, anchor); + assertThat(local(rule.next(anchor, anchor, UTC), UTC).toLocalDate()) + .isEqualTo(LocalDate.of(2024, 2, 29)); + } + + @Test + public void next_yearlyFrom29February_overEightYears() { + long anchor = at(KYIV, 2024, 2, 29, 8, 30); + RepeatRule rule = RepeatRule.every(1, RepeatRule.Unit.YEARS, anchor); + List dates = new ArrayList<>(); + long t = anchor; + for (int i = 0; i < 8; i++) { + t = rule.next(anchor, t, KYIV); + dates.add(local(t, KYIV).toLocalDate()); + } + assertThat(dates) + .containsExactly( + LocalDate.of(2025, 2, 28), + LocalDate.of(2026, 2, 28), + LocalDate.of(2027, 2, 28), + LocalDate.of(2028, 2, 29), + LocalDate.of(2029, 2, 28), + LocalDate.of(2030, 2, 28), + LocalDate.of(2031, 2, 28), + LocalDate.of(2032, 2, 29)) + .inOrder(); + } + + @Test + public void next_hoursAreElapsedTimeAcrossDst() { + // Midnight before the spring-forward night: 24 one-hour steps are 24 real hours, so the + // last lands at 01:00 local, not midnight. + long anchor = at(KYIV, 2025, 3, 30, 0, 0); + RepeatRule rule = RepeatRule.every(1, RepeatRule.Unit.HOURS, anchor); + long t = anchor; + for (int i = 0; i < 24; i++) { + long next = rule.next(anchor, t, KYIV); + assertThat(next - t).isEqualTo(Duration.ofHours(1).toMillis()); + t = next; + } + assertThat(local(t, KYIV)).isEqualTo(LocalDateTime.of(2025, 3, 31, 1, 0)); + + RepeatRule every24h = RepeatRule.every(24, RepeatRule.Unit.HOURS, anchor); + assertThat(local(every24h.next(anchor, anchor, KYIV), KYIV)) + .isEqualTo(LocalDateTime.of(2025, 3, 31, 1, 0)); + } + + @Test + public void next_daysKeepTheWallClockAcrossDst() { + long springAnchor = at(KYIV, 2025, 3, 29, 9, 0); + RepeatRule daily = RepeatRule.every(1, RepeatRule.Unit.DAYS, springAnchor); + long spring = daily.next(springAnchor, springAnchor, KYIV); + assertThat(local(spring, KYIV)).isEqualTo(LocalDateTime.of(2025, 3, 30, 9, 0)); + assertThat(spring - springAnchor).isEqualTo(Duration.ofHours(23).toMillis()); + + long autumnAnchor = at(KYIV, 2025, 10, 25, 9, 0); + RepeatRule weekly = RepeatRule.every(1, RepeatRule.Unit.WEEKS, autumnAnchor); + long autumn = weekly.next(autumnAnchor, autumnAnchor, KYIV); + assertThat(local(autumn, KYIV)).isEqualTo(LocalDateTime.of(2025, 11, 1, 9, 0)); + assertThat(autumn - autumnAnchor).isEqualTo(Duration.ofHours(7 * 24 + 1).toMillis()); + } + + @Test + public void next_aTimeSkippedByDst_returnsToItsWallClockAfterwards() { + // 03:30 does not exist on 30 March in Kyiv; that day rings an hour later, the next day + // is back at 03:30 because each occurrence is counted from the anchor. + long anchor = at(KYIV, 2025, 3, 29, 3, 30); + RepeatRule daily = RepeatRule.every(1, RepeatRule.Unit.DAYS, anchor); + long gapDay = daily.next(anchor, anchor, KYIV); + assertThat(local(gapDay, KYIV)).isEqualTo(LocalDateTime.of(2025, 3, 30, 4, 30)); + assertThat(local(daily.next(anchor, gapDay, KYIV), KYIV)) + .isEqualTo(LocalDateTime.of(2025, 3, 31, 3, 30)); + } + + @Test + public void next_catchesUpAfterTenMissedPeriods_inOneStep() { + long anchor = at(KYIV, 2025, 1, 1, 7, 15); + for (RepeatRule.Unit unit : RepeatRule.Unit.values()) { + RepeatRule rule = RepeatRule.every(3, unit, anchor); + long tenth = rule.occurrence(anchor, 10, KYIV); + long eleventh = rule.occurrence(anchor, 11, KYIV); + // The phone was off from just after the anchor until just after the 10th period. + long now = tenth + Duration.ofMinutes(5).toMillis(); + assertThat(rule.next(anchor, now, KYIV)).isEqualTo(eleventh); + } + } + + @Test + public void next_doesNotDriftWhenDeliveredLate() { + long anchor = at(UTC, 2026, 1, 1, 9, 0); + RepeatRule rule = RepeatRule.every(1, RepeatRule.Unit.DAYS, anchor); + // Delivered 20 minutes late each day: the next time is still 09:00. + long late = anchor + Duration.ofMinutes(20).toMillis(); + assertThat(local(rule.next(anchor, late, UTC), UTC)) + .isEqualTo(LocalDateTime.of(2026, 1, 2, 9, 0)); + } + + @Test + public void next_longGapsAreCheap() { + long anchor = at(UTC, 2000, 1, 1, 0, 0); + RepeatRule hourly = RepeatRule.every(1, RepeatRule.Unit.HOURS, anchor); + long now = at(UTC, 2026, 6, 15, 12, 30); + assertThat(local(hourly.next(anchor, now, UTC), UTC)) + .isEqualTo(LocalDateTime.of(2026, 6, 15, 13, 0)); + RepeatRule monthly = + RepeatRule.every(1, RepeatRule.Unit.MONTHS, at(UTC, 2000, 1, 31, 9, 0)); + assertThat(local(monthly.next(monthly.getAnchor(), now, UTC), UTC)) + .isEqualTo(LocalDateTime.of(2026, 6, 30, 9, 0)); + } +} diff --git a/app/src/test/java/com/pasich/mynotes/data/order/CustomOrderTest.java b/app/src/test/java/com/pasich/mynotes/data/order/CustomOrderTest.java new file mode 100644 index 00000000..d84464eb --- /dev/null +++ b/app/src/test/java/com/pasich/mynotes/data/order/CustomOrderTest.java @@ -0,0 +1,30 @@ +package com.pasich.mynotes.data.order; + +import static com.google.common.truth.Truth.assertThat; + +import org.junit.Test; + +public class CustomOrderTest { + + @Test + public void between_takesTheMidpointOrStepsPastAnEnd() { + assertThat(CustomOrder.between(4096L, 1024L)).isEqualTo(2560L); + assertThat(CustomOrder.between(null, 1024L)).isEqualTo(1024L + CustomOrder.STEP); + assertThat(CustomOrder.between(1024L, null)).isEqualTo(0L); + assertThat(CustomOrder.between(null, null)).isEqualTo(CustomOrder.STEP); + } + + @Test + public void between_asksForARenumberWhenNeighboursTouch() { + assertThat(CustomOrder.between(1025L, 1024L)).isNull(); + assertThat(CustomOrder.between(1024L, 1024L)).isNull(); + assertThat(CustomOrder.between(1026L, 1024L)).isEqualTo(1025L); + } + + @Test + public void renumbered_spreadsTopFirst() { + assertThat(CustomOrder.renumbered(3)) + .containsExactly(3 * CustomOrder.STEP, 2 * CustomOrder.STEP, CustomOrder.STEP) + .inOrder(); + } +} diff --git a/app/src/test/java/com/pasich/mynotes/data/sync/RoomSyncStoreNoOpApplyTest.java b/app/src/test/java/com/pasich/mynotes/data/sync/RoomSyncStoreNoOpApplyTest.java new file mode 100644 index 00000000..8d52adf8 --- /dev/null +++ b/app/src/test/java/com/pasich/mynotes/data/sync/RoomSyncStoreNoOpApplyTest.java @@ -0,0 +1,271 @@ +package com.pasich.mynotes.data.sync; + +import static com.google.common.truth.Truth.assertThat; +import static org.mockito.Mockito.mock; + +import android.content.Context; +import android.database.Cursor; +import androidx.room.Room; +import androidx.sqlite.db.SupportSQLiteDatabase; +import com.google.gson.JsonObject; +import com.pasich.mynotes.data.database.AppDatabase; +import com.pasich.mynotes.data.database.entities.SyncMetadataEntity; +import com.pasich.mynotes.data.model.Note; +import com.pasich.mynotes.data.model.Tag; +import com.pasich.mynotes.data.model.Task; +import com.pasich.mynotes.data.model.TaskCategory; +import com.pasich.mynotes.data.preferences.PreferenceHelper; +import io.reactivex.observers.BaseTestConsumer; +import io.reactivex.subscribers.TestSubscriber; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.RuntimeEnvironment; +import org.robolectric.annotation.Config; + +/** + * A sync that changes nothing must write nothing. + * + *

Every sync applies the whole merged snapshot. Re-applying each known record with a REPLACE + * insert or an update invalidated the notes, tags and tasks queries on every sync, so the main list + * was re-emitted and re-animated even when no other device had changed anything. The writes are + * counted with SQLite triggers on the same tables Room's invalidation tracker watches. + */ +@RunWith(RobolectricTestRunner.class) +@Config(sdk = 35) +public class RoomSyncStoreNoOpApplyTest { + + private static final String NOTE_ID = "11111111-1111-4111-8111-111111111111"; + private static final String EMPTY_LIST_NOTE_ID = "22222222-2222-4222-8222-222222222222"; + private static final String TAG_ID = "33333333-3333-4333-8333-333333333333"; + private static final String CATEGORY_ID = "44444444-4444-4444-8444-444444444444"; + private static final String TASK_ID = "55555555-5555-4555-8555-555555555555"; + private static final String[] WATCHED = + new String[] {"notes", "tags", "tasks", "task_categories", "sync_metadata"}; + + private AppDatabase db; + private RoomSyncStore store; + private int noteId; + + @Before + public void setUp() { + Context context = RuntimeEnvironment.getApplication(); + db = + Room.inMemoryDatabaseBuilder(context, AppDatabase.class) + .allowMainThreadQueries() + .build(); + store = new RoomSyncStore(context, db, mock(PreferenceHelper.class)); + + noteId = seedNote("Shopping", "Milk", null, NOTE_ID); + // The editor stores "no attachments" as "[]"; a received version carries none at all. + seedNote("Plain", "Body", "[]", EMPTY_LIST_NOTE_ID); + int tagId = (int) db.tagsDao().addTag(new Tag().create("Work")); + seedMetadata(SyncMetadata.RECORD_TYPE_TAG, tagId, TAG_ID); + int categoryId = + (int) db.taskCategoryDao().insertCategory(new TaskCategory("Home", "#123456")); + seedMetadata(SyncMetadata.RECORD_TYPE_CATEGORY, categoryId, CATEGORY_ID); + int taskId = (int) db.taskDao().insertTask(new Task("Call", categoryId)); + seedMetadata(SyncMetadata.RECORD_TYPE_TASK, taskId, TASK_ID); + } + + @After + public void tearDown() { + db.close(); + } + + @Test + public void applyingWhatThisDeviceAlreadyHoldsWritesNoRows() throws Exception { + SyncSnapshot first = store.readSnapshot(); + countWrites(); + + store.applySnapshot(first, Collections.emptyList()); + + // The first sync only records the versions the remote now holds. + Map writes = writesByTable(); + assertThat(writes.keySet()).containsExactly("sync_metadata"); + + SyncSnapshot second = store.readSnapshot(); + clearWrites(); + store.applySnapshot(second, Collections.emptyList()); + + // Every later sync of an unchanged library touches nothing at all. + assertThat(writesByTable()).isEmpty(); + assertThat(db.noteDao().getNoteSync(noteId).getTitle()).isEqualTo("Shopping"); + SyncMetadataEntity metadata = + db.syncMetadataDao().get(SyncMetadata.RECORD_TYPE_NOTE, noteId); + assertThat(metadata.syncedVersionId) + .isEqualTo(second.find(SyncRecord.Type.NOTE, NOTE_ID).getCanonicalPayloadHash()); + } + + @Test + public void aNoOpApplyDoesNotReEmitTheNotesList() throws Exception { + store.applySnapshot(store.readSnapshot(), Collections.emptyList()); + SyncSnapshot unchanged = store.readSnapshot(); + TestSubscriber> notes = db.noteDao().getNotesAll().test(); + notes.awaitCount(1, BaseTestConsumer.TestWaitStrategy.SLEEP_10MS, 5_000L); + assertThat(notes.valueCount()).isEqualTo(1); + + store.applySnapshot(unchanged, Collections.emptyList()); + notes.awaitCount(2, BaseTestConsumer.TestWaitStrategy.SLEEP_10MS, 1_000L); + assertThat(notes.valueCount()).isEqualTo(1); + + // The control: a version that does change the note is still delivered to the list. + store.applySnapshot( + new SyncSnapshot(Collections.singletonList(renamed(unchanged, "Groceries"))), + Collections.emptyList()); + notes.awaitCount(2, BaseTestConsumer.TestWaitStrategy.SLEEP_10MS, 5_000L); + assertThat(notes.valueCount()).isEqualTo(2); + notes.dispose(); + } + + @Test + public void aChangedRecordIsStillAppliedAndOnlyItIsWritten() throws Exception { + store.applySnapshot(store.readSnapshot(), Collections.emptyList()); + SyncSnapshot built = store.readSnapshot(); + SyncRecord edited = renamed(built, "Groceries"); + List merged = new ArrayList<>(); + for (SyncRecord record : built.getRecords()) { + merged.add(record.getId().equals(NOTE_ID) ? edited : record); + } + countWrites(); + + store.applySnapshot(new SyncSnapshot(merged), Collections.emptyList()); + + assertThat(db.noteDao().getNoteSync(noteId).getTitle()).isEqualTo("Groceries"); + SyncMetadataEntity metadata = + db.syncMetadataDao().get(SyncMetadata.RECORD_TYPE_NOTE, noteId); + assertThat(metadata.updatedAt).isEqualTo(edited.getUpdatedAt().toEpochMilli()); + assertThat(metadata.syncedVersionId).isEqualTo(edited.getCanonicalPayloadHash()); + assertThat(writesByTable().keySet()).containsExactly("notes", "sync_metadata"); + } + + @Test + public void aTombstoneIsStillApplied() throws Exception { + store.applySnapshot(store.readSnapshot(), Collections.emptyList()); + SyncRecord built = store.readSnapshot().find(SyncRecord.Type.NOTE, NOTE_ID); + Instant deletedAt = built.getUpdatedAt().plusSeconds(1); + SyncRecord tombstone = + SyncRecord.tombstone(SyncRecord.Type.NOTE, NOTE_ID, deletedAt, deletedAt); + + store.applySnapshot( + new SyncSnapshot(Collections.singletonList(tombstone)), Collections.emptyList()); + + assertThat(db.noteDao().getNoteSync(noteId)).isNull(); + SyncMetadataEntity metadata = + db.syncMetadataDao().get(SyncMetadata.RECORD_TYPE_NOTE, noteId); + assertThat(metadata.deletedAt).isEqualTo(deletedAt.toEpochMilli()); + assertThat(metadata.syncedVersionId).isEqualTo(tombstone.getCanonicalPayloadHash()); + + // Applied again, the same deletion has nothing left to do. + countWrites(); + store.applySnapshot( + new SyncSnapshot(Collections.singletonList(tombstone)), Collections.emptyList()); + assertThat(writesByTable()).isEmpty(); + } + + @Test + public void aChangedTaskIsStillAppliedWhileItsCategoryIsLeftAlone() throws Exception { + store.applySnapshot(store.readSnapshot(), Collections.emptyList()); + SyncSnapshot built = store.readSnapshot(); + SyncRecord task = built.find(SyncRecord.Type.TASK, TASK_ID); + JsonObject payload = task.getPayload().deepCopy(); + payload.addProperty("title", "Call back"); + List merged = new ArrayList<>(); + for (SyncRecord record : built.getRecords()) { + merged.add( + record.getId().equals(TASK_ID) + ? SyncRecord.live( + SyncRecord.Type.TASK, + TASK_ID, + task.getUpdatedAt().plusSeconds(1), + payload) + : record); + } + countWrites(); + + store.applySnapshot(new SyncSnapshot(merged), Collections.emptyList()); + + long taskId = + db.syncMetadataDao().getByStableId(SyncMetadata.RECORD_TYPE_TASK, TASK_ID).localId; + Task stored = db.taskDao().getTaskSync((int) taskId); + assertThat(stored.getTitle()).isEqualTo("Call back"); + long categoryId = + db.syncMetadataDao() + .getByStableId(SyncMetadata.RECORD_TYPE_CATEGORY, CATEGORY_ID) + .localId; + assertThat(stored.getCategoryId()).isEqualTo((int) categoryId); + assertThat(writesByTable().keySet()).containsExactly("tasks", "sync_metadata"); + } + + // ---- helpers ---- + + private SyncRecord renamed(SyncSnapshot snapshot, String title) { + SyncRecord note = snapshot.find(SyncRecord.Type.NOTE, NOTE_ID); + JsonObject payload = note.getPayload().deepCopy(); + payload.addProperty("b", title); + return SyncRecord.live( + SyncRecord.Type.NOTE, NOTE_ID, note.getUpdatedAt().plusSeconds(1), payload); + } + + private int seedNote(String title, String value, String attachmentsJson, String stableId) { + Note note = new Note().create(title, value, 1_000L, ""); + note.setAttachments(attachmentsJson); + int id = db.noteDao().addNote(note).intValue(); + seedMetadata(SyncMetadata.RECORD_TYPE_NOTE, id, stableId); + return id; + } + + private void seedMetadata(String recordType, long localId, String stableId) { + db.syncMetadataDao() + .insertIfAbsent( + new SyncMetadataEntity(recordType, localId, stableId, 1_000L, null)); + } + + private SupportSQLiteDatabase sql() { + return db.getOpenHelper().getWritableDatabase(); + } + + /** Installs triggers that log every row written to the watched tables from now on. */ + private void countWrites() { + sql().execSQL("CREATE TEMP TABLE IF NOT EXISTS row_writes(tbl TEXT NOT NULL)"); + for (String table : WATCHED) { + for (String operation : new String[] {"INSERT", "UPDATE", "DELETE"}) { + sql().execSQL( + "CREATE TEMP TRIGGER IF NOT EXISTS count_" + + table + + "_" + + operation + + " AFTER " + + operation + + " ON " + + table + + " BEGIN INSERT INTO row_writes VALUES('" + + table + + "'); END"); + } + } + clearWrites(); + } + + private void clearWrites() { + sql().execSQL("DELETE FROM row_writes"); + } + + private Map writesByTable() { + Map writes = new HashMap<>(); + try (Cursor cursor = sql().query("SELECT tbl, COUNT(*) FROM row_writes GROUP BY tbl")) { + while (cursor.moveToNext()) { + writes.put(cursor.getString(0), cursor.getInt(1)); + } + } + return writes; + } +} diff --git a/app/src/test/java/com/pasich/mynotes/data/sync/RoomSyncStoreReminderTest.java b/app/src/test/java/com/pasich/mynotes/data/sync/RoomSyncStoreReminderTest.java new file mode 100644 index 00000000..b1c9db54 --- /dev/null +++ b/app/src/test/java/com/pasich/mynotes/data/sync/RoomSyncStoreReminderTest.java @@ -0,0 +1,173 @@ +package com.pasich.mynotes.data.sync; + +import static com.google.common.truth.Truth.assertThat; +import static org.mockito.Mockito.mock; +import static org.robolectric.Shadows.shadowOf; + +import android.app.AlarmManager; +import android.content.Context; +import android.content.Intent; +import androidx.room.Room; +import com.google.gson.Gson; +import com.google.gson.JsonNull; +import com.google.gson.JsonObject; +import com.pasich.mynotes.data.database.AppDatabase; +import com.pasich.mynotes.data.database.entities.SyncMetadataEntity; +import com.pasich.mynotes.data.model.Note; +import com.pasich.mynotes.data.model.RepeatRule; +import com.pasich.mynotes.data.preferences.PreferenceHelper; +import com.pasich.mynotes.utils.reminder.ReminderManager; +import com.pasich.mynotes.utils.reminder.SnoozeStore; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.RuntimeEnvironment; +import org.robolectric.annotation.Config; +import org.robolectric.shadows.ShadowAlarmManager; + +/** + * A reminder set, moved or turned off on another device arrives here only as a note row; the alarms + * on this device must follow it as soon as the sync applies, not at the next app start. + */ +@RunWith(RobolectricTestRunner.class) +@Config(sdk = 35) +public class RoomSyncStoreReminderTest { + + private static final String NOTE_ID = "11111111-1111-4111-8111-111111111111"; + private static final String REMOTE_ID = "66666666-6666-4666-8666-666666666666"; + private static final long HOUR = 3_600_000L; + + private Context context; + private AppDatabase db; + private RoomSyncStore store; + private ShadowAlarmManager alarms; + private int noteId; + private long future; + + @Before + public void setUp() throws Exception { + context = RuntimeEnvironment.getApplication(); + db = + Room.inMemoryDatabaseBuilder(context, AppDatabase.class) + .allowMainThreadQueries() + .build(); + store = new RoomSyncStore(context, db, mock(PreferenceHelper.class)); + alarms = shadowOf((AlarmManager) context.getSystemService(Context.ALARM_SERVICE)); + ShadowAlarmManager.setCanScheduleExactAlarms(true); + future = System.currentTimeMillis() + 5 * HOUR; + + Note note = new Note().create("Pills", "Twice a day", 1_000L, ""); + noteId = db.noteDao().addNote(note).intValue(); + db.syncMetadataDao() + .insertIfAbsent( + new SyncMetadataEntity( + SyncMetadata.RECORD_TYPE_NOTE, noteId, NOTE_ID, 1_000L, null)); + store.applySnapshot(store.readSnapshot(), Collections.emptyList()); + } + + @After + public void tearDown() { + db.close(); + ShadowAlarmManager.reset(); + context.getSharedPreferences("reminder_snoozes", Context.MODE_PRIVATE) + .edit() + .clear() + .commit(); + } + + @Test + public void aReminderSetElsewhereIsArmedHere() throws Exception { + String rule = RepeatRule.every(3, RepeatRule.Unit.HOURS, future).serialize(); + applyNote(withReminder(future, rule)); + + Note stored = db.noteDao().getNoteSync(noteId); + assertThat(stored.getReminderRepeat()).isEqualTo(rule); + List armed = alarmsFor(noteId, null); + assertThat(armed).hasSize(1); + assertThat(armed.get(0).getTriggerAtMs()).isEqualTo(future); + } + + @Test + public void aReminderTurnedOffElsewhereIsCancelledHere() throws Exception { + applyNote(withReminder(future, "DAILY")); + assertThat(alarmsFor(noteId, null)).hasSize(1); + + applyNote(withReminder(null, "NONE")); + + assertThat(alarmsFor(noteId, null)).isEmpty(); + } + + @Test + public void aNoteDeletedElsewhereLosesEveryAlarm() throws Exception { + applyNote(withReminder(future, "DAILY")); + ReminderManager.scheduleSnooze(context, noteId, future - HOUR, 0); + SyncRecord built = store.readSnapshot().find(SyncRecord.Type.NOTE, NOTE_ID); + Instant deletedAt = built.getUpdatedAt().plusSeconds(1); + + store.applySnapshot( + new SyncSnapshot( + Collections.singletonList( + SyncRecord.tombstone( + SyncRecord.Type.NOTE, NOTE_ID, deletedAt, deletedAt))), + Collections.emptyList()); + + assertThat(alarmsFor(noteId, null)).isEmpty(); + assertThat(alarmsFor(noteId, ReminderManager.ACTION_SNOOZE)).isEmpty(); + assertThat(new SnoozeStore(context).get(noteId)).isNull(); + } + + @Test + public void aNewNoteWithAReminderFromElsewhereIsArmed() throws Exception { + Note remote = new Note().create("Rent", "Pay", 2_000L, ""); + remote.setReminderTime(future); + remote.setReminderRepeat("MONTHLY"); + JsonObject payload = new Gson().toJsonTree(remote).getAsJsonObject(); + List records = new ArrayList<>(store.readSnapshot().getRecords()); + records.add( + SyncRecord.live( + SyncRecord.Type.NOTE, REMOTE_ID, Instant.ofEpochMilli(3_000L), payload)); + + store.applySnapshot(new SyncSnapshot(records), Collections.emptyList()); + + long localId = + db.syncMetadataDao() + .getByStableId(SyncMetadata.RECORD_TYPE_NOTE, REMOTE_ID) + .localId; + assertThat(alarmsFor((int) localId, null)).hasSize(1); + assertThat(alarmsFor((int) localId, null).get(0).getTriggerAtMs()).isEqualTo(future); + } + + // ---- helpers ---- + + private SyncRecord withReminder(Long time, String repeat) throws Exception { + SyncRecord note = store.readSnapshot().find(SyncRecord.Type.NOTE, NOTE_ID); + JsonObject payload = note.getPayload().deepCopy(); + if (time == null) payload.add("j", JsonNull.INSTANCE); + else payload.addProperty("j", time); + payload.addProperty("k", repeat); + return SyncRecord.live( + SyncRecord.Type.NOTE, NOTE_ID, note.getUpdatedAt().plusSeconds(1), payload); + } + + private void applyNote(SyncRecord record) throws Exception { + store.applySnapshot( + new SyncSnapshot(Collections.singletonList(record)), Collections.emptyList()); + } + + private List alarmsFor(int id, String action) { + List found = new ArrayList<>(); + for (ShadowAlarmManager.ScheduledAlarm alarm : alarms.getScheduledAlarms()) { + Intent intent = shadowOf(alarm.operation).getSavedIntent(); + if (intent.getIntExtra(ReminderManager.EXTRA_NOTE_ID, -1) != id) continue; + String a = intent.getAction(); + if (action == null ? a == null : action.equals(a)) found.add(alarm); + } + return found; + } +} diff --git a/app/src/test/java/com/pasich/mynotes/data/sync/SyncMetadataTest.java b/app/src/test/java/com/pasich/mynotes/data/sync/SyncMetadataTest.java index 5f0bd6c2..bc6e1667 100644 --- a/app/src/test/java/com/pasich/mynotes/data/sync/SyncMetadataTest.java +++ b/app/src/test/java/com/pasich/mynotes/data/sync/SyncMetadataTest.java @@ -43,6 +43,45 @@ public void stripDeviceLocalFields_removesRoomKeysAndAttachmentPaths() { assertThat(task.get("title").getAsString()).isEqualTo("Buy milk"); } + @Test + public void stripDeviceLocalFields_dropsTheCustomPositionSoNoteHashesDoNotChange() { + com.google.gson.Gson gson = new com.google.gson.Gson(); + com.pasich.mynotes.data.model.Note here = + new com.pasich.mynotes.data.model.Note().create("Shopping", "milk", 10L, "work"); + here.setId(4); + here.setCustomPosition(4096L); + com.pasich.mynotes.data.model.Note elsewhere = + new com.pasich.mynotes.data.model.Note().create("Shopping", "milk", 10L, "work"); + elsewhere.setId(9); + elsewhere.setCustomPosition(1024L); + + JsonObject first = gson.toJsonTree(here).getAsJsonObject(); + JsonObject second = gson.toJsonTree(elsewhere).getAsJsonObject(); + assertThat(first.has("n")).isTrue(); + // What a client from before the custom order serialized: the same note, no "n". + JsonObject older = gson.toJsonTree(here).getAsJsonObject(); + older.remove("n"); + + SyncMetadata.stripDeviceLocalFields(SyncMetadata.RECORD_TYPE_NOTE, first); + SyncMetadata.stripDeviceLocalFields(SyncMetadata.RECORD_TYPE_NOTE, second); + SyncMetadata.stripDeviceLocalFields(SyncMetadata.RECORD_TYPE_NOTE, older); + + assertThat(first.has("n")).isFalse(); + // Two devices ordering the same note differently, and a client that never heard of the + // order, all describe one version: a drag is not an edit and causes no conflict. + assertThat(hash(first)).isEqualTo(hash(second)); + assertThat(hash(first)).isEqualTo(hash(older)); + } + + private static String hash(JsonObject payload) { + return SyncRecord.live( + SyncRecord.Type.NOTE, + "00000000-0000-4000-8000-000000000001", + java.time.Instant.EPOCH, + payload) + .getCanonicalPayloadHash(); + } + @Test public void stripDeviceLocalFields_leavesPreferencesUntouched() { // PreferencesBackup is serialized with the same short Gson aliases as Note, so "a" is the diff --git a/app/src/test/java/com/pasich/mynotes/data/sync/SyncMutationCoordinatorHistoryTest.java b/app/src/test/java/com/pasich/mynotes/data/sync/SyncMutationCoordinatorHistoryTest.java new file mode 100644 index 00000000..083b5e27 --- /dev/null +++ b/app/src/test/java/com/pasich/mynotes/data/sync/SyncMutationCoordinatorHistoryTest.java @@ -0,0 +1,242 @@ +package com.pasich.mynotes.data.sync; + +import static com.google.common.truth.Truth.assertThat; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.pasich.mynotes.data.database.dao.NoteDao; +import com.pasich.mynotes.data.database.dao.SyncMetadataDao; +import com.pasich.mynotes.data.database.dao.TagsDao; +import com.pasich.mynotes.data.database.dao.TaskCategoryDao; +import com.pasich.mynotes.data.database.dao.TaskDao; +import com.pasich.mynotes.data.database.dao.Transactions; +import com.pasich.mynotes.data.database.entities.NoteVersionEntity; +import com.pasich.mynotes.data.history.FakeNoteVersionDao; +import com.pasich.mynotes.data.history.NoteHistory; +import com.pasich.mynotes.data.history.NoteVersionReason; +import com.pasich.mynotes.data.model.Note; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.junit.Before; +import org.junit.Test; + +/** Version history as the coordinator keeps it: when a snapshot is due, the cap, the cleanup. */ +public class SyncMutationCoordinatorHistoryTest { + + private static final long MINUTE = 60_000L; + + private final Map notes = new HashMap<>(); + private final long[] now = {1_000_000L}; + private NoteDao noteDao; + private SyncMetadataDao syncMetadataDao; + private FakeNoteVersionDao versions; + private SyncMutationCoordinator coordinator; + + @Before + public void setUp() { + noteDao = mock(NoteDao.class); + syncMetadataDao = mock(SyncMetadataDao.class); + versions = new FakeNoteVersionDao(); + when(noteDao.getNoteSync(anyInt())) + .thenAnswer(call -> copy(notes.get(call.getArgument(0)))); + doAnswer( + call -> { + Note stored = notes.get((Integer) call.getArgument(0)); + stored.setTitle(call.getArgument(1)); + stored.setValue(call.getArgument(2)); + stored.setValueJson(call.getArgument(3)); + stored.setDate(call.getArgument(4)); + stored.setAttachments(call.getArgument(6)); + return null; + }) + .when(noteDao) + .updateNoteContent( + anyInt(), + anyString(), + anyString(), + anyString(), + anyLong(), + anyString(), + org.mockito.ArgumentMatchers.any()); + coordinator = + new SyncMutationCoordinator( + new SyncMutationCoordinator.TransactionExecutor() { + @Override + public T run( + SyncMutationCoordinator.TransactionCallable callable) { + return callable.call(); + } + }, + noteDao, + mock(TaskDao.class), + mock(TagsDao.class), + mock(TaskCategoryDao.class), + mock(Transactions.class), + syncMetadataDao, + () -> now[0], + () -> "stable", + (note, previousId) -> false, + new NoteHistory(versions, noteId -> "stable-" + noteId)); + store(7, "Shopping", "milk"); + } + + @Test + public void updateNoteContent_keepsTheReplacedTextOnlyOncePerInterval() { + coordinator.updateNoteContent(edit(7, "Shopping", "milk, bread")); + now[0] += MINUTE; + coordinator.updateNoteContent(edit(7, "Shopping", "milk, bread, eggs")); + + // The first save of a session keeps what was there before it; the saves that follow a + // few seconds or minutes later do not each add a near-identical copy. + List kept = versions.getForNoteSync(7); + assertThat(kept).hasSize(1); + assertThat(kept.get(0).value).isEqualTo("milk"); + assertThat(kept.get(0).reason).isEqualTo(NoteVersionReason.AUTOSAVE.name()); + assertThat(kept.get(0).noteStableId).isEqualTo("stable-7"); + + now[0] += NoteHistory.AUTOSAVE_INTERVAL_MILLIS; + coordinator.updateNoteContent(edit(7, "Shopping", "milk, bread, eggs, tea")); + + assertThat(versions.getForNoteSync(7)).hasSize(2); + assertThat(versions.getLatest(7).value).isEqualTo("milk, bread, eggs"); + } + + @Test + public void updateNoteContent_keepsALargeDeletionAtOnce() { + String longText = "a".repeat(NoteHistory.LARGE_EDIT_CHARS + 50); + store(8, "Essay", longText); + coordinator.updateNoteContent(edit(8, "Essay", longText + " more")); + now[0] += MINUTE; + + // Select-all and type over it, a minute later: inside the interval, kept anyway. + coordinator.updateNoteContent(edit(8, "Essay", "oops")); + + List kept = versions.getForNoteSync(8); + assertThat(kept).hasSize(2); + assertThat(kept.get(0).value).isEqualTo(longText + " more"); + } + + @Test + public void updateNoteContent_keepsNothingForAnUnchangedOrEmptyNote() { + coordinator.updateNoteContent(edit(7, "Shopping", "milk")); + store(9, "", ""); + coordinator.updateNoteContent(edit(9, "First words", "")); + + assertThat(versions.rows).isEmpty(); + } + + @Test + public void history_isCappedPerNoteWithTheOldestDroppedFirst() { + for (int i = 0; i < NoteHistory.MAX_VERSIONS_PER_NOTE + 5; i++) { + now[0] += NoteHistory.AUTOSAVE_INTERVAL_MILLIS; + coordinator.updateNoteContent(edit(7, "Shopping", "item " + i)); + } + + List kept = versions.getForNoteSync(7); + assertThat(kept).hasSize(NoteHistory.MAX_VERSIONS_PER_NOTE); + // Newest first; the very first states ("milk", "item 0"...) were pruned. + assertThat(kept.get(0).value).isEqualTo("item " + (NoteHistory.MAX_VERSIONS_PER_NOTE + 3)); + assertThat(kept.get(kept.size() - 1).value).isEqualTo("item 4"); + } + + @Test + public void restoreNoteVersion_keepsTheCurrentTextAndWritesTheVersionAsAnEdit() { + coordinator.updateNoteContent(edit(7, "Shopping", "milk, bread")); + long versionId = versions.getLatest(7).id; + now[0] += MINUTE; + + assertThat(coordinator.restoreNoteVersion(7, versionId)).isTrue(); + + assertThat(notes.get(7).getValue()).isEqualTo("milk"); + assertThat(notes.get(7).getDate()).isEqualTo(now[0]); + NoteVersionEntity before = versions.getLatest(7); + assertThat(before.value).isEqualTo("milk, bread"); + assertThat(before.reason).isEqualTo(NoteVersionReason.PRE_RESTORE.name()); + // An ordinary edit as far as sync is concerned. + verify(syncMetadataDao).touch(SyncMetadata.RECORD_TYPE_NOTE, 7L, now[0]); + } + + @Test + public void restoreNoteVersion_refusesAVersionOfAnotherNote() { + store(8, "Other", "text"); + coordinator.updateNoteContent(edit(8, "Other", "changed")); + long otherVersion = versions.getLatest(8).id; + + assertThat(coordinator.restoreNoteVersion(7, otherVersion)).isFalse(); + assertThat(notes.get(7).getValue()).isEqualTo("milk"); + } + + @Test + public void deletingNotesForGood_removesTheirHistory() { + store(8, "Other", "text"); + coordinator.updateNoteContent(edit(7, "Shopping", "milk, bread")); + coordinator.updateNoteContent(edit(8, "Other", "changed")); + + coordinator.deleteNote(notes.get(7)); + assertThat(versions.getForNoteSync(7)).isEmpty(); + assertThat(versions.getForNoteSync(8)).hasSize(1); + + when(noteDao.getTrashNoteIdsSync()).thenReturn(new ArrayList<>(List.of(8))); + coordinator.deleteAllTrashNotes(); + assertThat(versions.rows).isEmpty(); + } + + @Test + public void movingToTrash_keepsHistory() { + coordinator.updateNoteContent(edit(7, "Shopping", "milk, bread")); + + coordinator.moveNoteToTrash(7); + + assertThat(versions.getForNoteSync(7)).hasSize(1); + } + + @Test + public void clearAllUserData_removesAllHistory() { + coordinator.updateNoteContent(edit(7, "Shopping", "milk, bread")); + when(noteDao.getAllNoteIdsSync()).thenReturn(new ArrayList<>(List.of(7))); + + coordinator.clearAllUserData(); + + assertThat(versions.rows).isEmpty(); + } + + @Test + public void sweepOrphanNoteVersions_removesHistoryWithoutItsNote() { + coordinator.updateNoteContent(edit(7, "Shopping", "milk, bread")); + versions.liveNoteIds.add(8); + + assertThat(coordinator.sweepOrphanNoteVersions()).isEqualTo(1); + assertThat(versions.rows).isEmpty(); + } + + private void store(int id, String title, String value) { + Note note = new Note().create(title, value, 10L, ""); + note.setId(id); + note.setValueJson(""); + note.setAttachments("[]"); + notes.put(id, note); + } + + private static Note edit(int id, String title, String value) { + Note note = new Note().create(title, value, 20L, ""); + note.setId(id); + note.setValueJson(""); + note.setAttachments("[]"); + return note; + } + + private static Note copy(Note source) { + if (source == null) return null; + Note copy = new Note(); + copy.copyFrom(source); + copy.setId(source.getId()); + return copy; + } +} diff --git a/app/src/test/java/com/pasich/mynotes/data/sync/SyncMutationCoordinatorOrderTest.java b/app/src/test/java/com/pasich/mynotes/data/sync/SyncMutationCoordinatorOrderTest.java new file mode 100644 index 00000000..5a865390 --- /dev/null +++ b/app/src/test/java/com/pasich/mynotes/data/sync/SyncMutationCoordinatorOrderTest.java @@ -0,0 +1,156 @@ +package com.pasich.mynotes.data.sync; + +import static com.google.common.truth.Truth.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.pasich.mynotes.data.database.dao.NoteDao; +import com.pasich.mynotes.data.database.dao.SyncMetadataDao; +import com.pasich.mynotes.data.database.dao.TagsDao; +import com.pasich.mynotes.data.database.dao.TaskCategoryDao; +import com.pasich.mynotes.data.database.dao.TaskDao; +import com.pasich.mynotes.data.database.dao.Transactions; +import com.pasich.mynotes.data.model.Note; +import com.pasich.mynotes.data.order.CustomOrder; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import org.junit.Before; +import org.junit.Test; + +/** The custom note order as the coordinator keeps it: local writes, new notes on top. */ +public class SyncMutationCoordinatorOrderTest { + + private final Map notes = new LinkedHashMap<>(); + private NoteDao noteDao; + private SyncMetadataDao syncMetadataDao; + private SyncMutationCoordinator coordinator; + + @Before + public void setUp() { + noteDao = mock(NoteDao.class); + syncMetadataDao = mock(SyncMetadataDao.class); + when(noteDao.getCustomPositionSync(anyInt())) + .thenAnswer( + call -> { + Note note = notes.get((Integer) call.getArgument(0)); + return note == null ? null : note.getCustomPosition(); + }); + when(noteDao.getHighestCustomPositionSync()) + .thenAnswer( + call -> { + long highest = 0; + for (Note note : notes.values()) + highest = Math.max(highest, note.getCustomPosition()); + return highest; + }); + when(noteDao.getAllNotesSync()).thenAnswer(call -> new ArrayList<>(notes.values())); + doAnswer( + call -> { + notes.get((Integer) call.getArgument(0)) + .setCustomPosition(call.getArgument(1)); + return null; + }) + .when(noteDao) + .setCustomPositionSync(anyInt(), anyLong()); + coordinator = + new SyncMutationCoordinator( + new SyncMutationCoordinator.TransactionExecutor() { + @Override + public T run( + SyncMutationCoordinator.TransactionCallable callable) { + return callable.call(); + } + }, + noteDao, + mock(TaskDao.class), + mock(TagsDao.class), + mock(TaskCategoryDao.class), + mock(Transactions.class), + syncMetadataDao, + () -> 5_000L, + () -> "stable"); + put(1, 3 * CustomOrder.STEP); + put(2, 2 * CustomOrder.STEP); + put(3, CustomOrder.STEP); + } + + @Test + public void moveNoteInCustomOrder_writesOneRowAndIsNotAnEdit() { + // Note 3 dropped between notes 1 and 2. + coordinator.moveNoteInCustomOrder(3, 1, 2); + + assertThat(notes.get(3).getCustomPosition()).isEqualTo(2560L); + verify(noteDao).setCustomPositionSync(3, 2560L); + // A local arrangement: no sync timestamp moves, so nothing is published. + verify(syncMetadataDao, never()).touch(anyString(), anyLong(), anyLong()); + verify(syncMetadataDao, never()).insertIfAbsent(any()); + } + + @Test + public void moveNoteInCustomOrder_toTheTopAndTheBottom() { + coordinator.moveNoteInCustomOrder(3, null, 1); + assertThat(notes.get(3).getCustomPosition()).isEqualTo(4 * CustomOrder.STEP); + + coordinator.moveNoteInCustomOrder(1, 2, null); + assertThat(notes.get(1).getCustomPosition()).isEqualTo(CustomOrder.STEP); + } + + @Test + public void moveNoteInCustomOrder_renumbersWhenTheGapIsUsedUp() { + put(1, 1025L); + put(2, 1024L); + put(3, 10L); + + coordinator.moveNoteInCustomOrder(3, 1, 2); + + // Renumbered top first, then placed between its new neighbours. + assertThat(notes.get(1).getCustomPosition()).isEqualTo(3 * CustomOrder.STEP); + assertThat(notes.get(2).getCustomPosition()).isEqualTo(2 * CustomOrder.STEP); + assertThat(notes.get(3).getCustomPosition()).isEqualTo(2560L); + } + + @Test + public void insertNote_goesToTheTopOfTheCustomOrder() { + Note fresh = new Note().create("New", "", 1L, ""); + when(noteDao.addNote(fresh)).thenReturn(9L); + + coordinator.insertNote(fresh); + + assertThat(fresh.getCustomPosition()).isEqualTo(4 * CustomOrder.STEP); + } + + @Test + public void insertNotes_keepsAPlaceFromTheBackupAndPlacesTheRestOnTopNewestHighest() { + Note placed = new Note().create("Placed", "", 1L, ""); + placed.setCustomPosition(1500L); + Note older = new Note().create("Older", "", 10L, ""); + Note newer = new Note().create("Newer", "", 20L, ""); + when(noteDao.addNotes(org.mockito.ArgumentMatchers.anyList())) + .thenReturn(new long[] {10L, 11L, 12L}); + + coordinator.insertNotes(new ArrayList<>(List.of(placed, older, newer))); + + assertThat(placed.getCustomPosition()).isEqualTo(1500L); + assertThat(older.getCustomPosition()).isEqualTo(4 * CustomOrder.STEP); + assertThat(newer.getCustomPosition()).isEqualTo(5 * CustomOrder.STEP); + } + + private void put(int id, long position) { + Note note = notes.get(id); + if (note == null) { + note = new Note().create("n" + id, "", id, ""); + note.setId(id); + notes.put(id, note); + } + note.setCustomPosition(position); + } +} diff --git a/app/src/test/java/com/pasich/mynotes/data/sync/SyncMutationCoordinatorTest.java b/app/src/test/java/com/pasich/mynotes/data/sync/SyncMutationCoordinatorTest.java index f50a9f22..38bee94a 100644 --- a/app/src/test/java/com/pasich/mynotes/data/sync/SyncMutationCoordinatorTest.java +++ b/app/src/test/java/com/pasich/mynotes/data/sync/SyncMutationCoordinatorTest.java @@ -256,6 +256,57 @@ public void deleteAllTrashNotes_marksEveryDeletedNoteWithSameBatchTimestamp() { assertThat(second.deletedAt).isEqualTo(1_000L); } + @Test + public void clearAllUserData_deletesEverythingAndLeavesATombstoneForEachRecord() { + syncMetadataDao.insertIfAbsent( + new SyncMetadataEntity( + SyncMetadata.RECORD_TYPE_NOTE, 1L, "note-stable", 90L, null)); + when(noteDao.getAllNoteIdsSync()).thenReturn(List.of(1, 2)); + when(taskDao.getAllTaskIdsSync()).thenReturn(List.of(5)); + Tag tag = new Tag().create("Work"); + tag.id = 7; + when(tagsDao.getUserTagsSync()).thenReturn(List.of(tag)); + TaskCategory category = new TaskCategory("Home", "#000000"); + category.setId(8); + when(taskCategoryDao.getCategoriesSync()).thenReturn(List.of(category)); + + SyncMutationCoordinator clearing = + new SyncMutationCoordinator( + new SyncMutationCoordinator.TransactionExecutor() { + @Override + public T run( + SyncMutationCoordinator.TransactionCallable callable) { + return callable.call(); + } + }, + noteDao, + taskDao, + tagsDao, + taskCategoryDao, + transactions, + syncMetadataDao, + new FixedTimeProvider(1_000L), + new QueueStableIdGenerator("s1", "s2", "s3", "s4", "s5")); + + clearing.clearAllUserData(); + + verify(noteDao).deleteAllNotes(); + verify(taskDao).deleteAllTasks(); + verify(tagsDao).deleteUserTags(); + verify(taskCategoryDao).deleteAllCategories(); + SyncMetadataEntity seeded = syncMetadataDao.get(SyncMetadata.RECORD_TYPE_NOTE, 1L); + assertThat(seeded.stableId).isEqualTo("note-stable"); + assertThat(seeded.deletedAt).isEqualTo(1_000L); + assertThat(syncMetadataDao.get(SyncMetadata.RECORD_TYPE_NOTE, 2L).deletedAt) + .isEqualTo(1_000L); + assertThat(syncMetadataDao.get(SyncMetadata.RECORD_TYPE_TASK, 5L).deletedAt) + .isEqualTo(1_000L); + assertThat(syncMetadataDao.get(SyncMetadata.RECORD_TYPE_TAG, 7L).deletedAt) + .isEqualTo(1_000L); + assertThat(syncMetadataDao.get(SyncMetadata.RECORD_TYPE_CATEGORY, 8L).deletedAt) + .isEqualTo(1_000L); + } + @Test public void insertNotes_usesSingleImportTimestampForWholeBatch() { List notes = new ArrayList<>(); diff --git a/app/src/test/java/com/pasich/mynotes/extendedEditor/NoteEditorViewLayoutTest.java b/app/src/test/java/com/pasich/mynotes/extendedEditor/NoteEditorViewLayoutTest.java new file mode 100644 index 00000000..bb409e26 --- /dev/null +++ b/app/src/test/java/com/pasich/mynotes/extendedEditor/NoteEditorViewLayoutTest.java @@ -0,0 +1,40 @@ +package com.pasich.mynotes.extendedEditor; + +import static com.google.common.truth.Truth.assertThat; +import static org.robolectric.Shadows.shadowOf; + +import android.app.Activity; +import android.os.Looper; +import android.view.View; +import android.webkit.WebView; +import android.widget.FrameLayout; +import com.pasich.mynotes.R; +import java.time.Duration; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.Robolectric; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; + +@RunWith(RobolectricTestRunner.class) +@Config(sdk = 35) +public class NoteEditorViewLayoutTest { + + @Test + public void beforeTheNoteIsShown_thePageTakesPartInLayoutButIsNotDrawn() { + Activity activity = Robolectric.buildActivity(Activity.class).setup().get(); + activity.setTheme(R.style.DefaultTheme); + FrameLayout root = new FrameLayout(activity); + NoteEditorView editor = new NoteEditorView(activity, null); + root.addView(editor, new FrameLayout.LayoutParams(480, 800)); + activity.setContentView(root); + shadowOf(Looper.getMainLooper()).idleFor(Duration.ofMillis(200)); + + // The page places the caret and the saved position as soon as the note is loaded, before + // it is faded in: it must already be laid out at the size it will be shown at, so it is + // kept visible and only transparent. (Robolectric does not size a WebView itself.) + WebView page = editor.getWebView(); + assertThat(page.getVisibility()).isEqualTo(View.VISIBLE); + assertThat(page.getAlpha()).isEqualTo(0f); + } +} diff --git a/app/src/test/java/com/pasich/mynotes/extendedEditor/attach/AttachmentCleanerTest.java b/app/src/test/java/com/pasich/mynotes/extendedEditor/attach/AttachmentCleanerTest.java index 87245e30..8c16c975 100644 --- a/app/src/test/java/com/pasich/mynotes/extendedEditor/attach/AttachmentCleanerTest.java +++ b/app/src/test/java/com/pasich/mynotes/extendedEditor/attach/AttachmentCleanerTest.java @@ -5,6 +5,9 @@ import java.io.File; import java.nio.charset.StandardCharsets; import java.nio.file.Files; +import java.util.HashSet; +import java.util.Set; +import org.junit.After; import org.junit.Before; import org.junit.Rule; import org.junit.Test; @@ -190,6 +193,73 @@ public void aReferenceToAnotherNotesFolderDoesNotProtectThisOne() throws Excepti assertThat(orphan.exists()).isFalse(); } + private static final long NOW = 1_800_000_000_000L; + + @Test + public void freshUploadWhoseBlockIsNotSavedYetIsKept() throws Exception { + // The image was stored, but the block that references it has not reached the database. + File uploaded = write("1731000000005_123456.jpg", "just picked"); + uploaded.setLastModified(NOW - AttachmentCleaner.GRACE_PERIOD_MS - 60_000); + Set recent = new HashSet<>(); + recent.add("1731000000005_123456.jpg"); + + AttachmentCleaner.Result result = + AttachmentCleaner.cleanup(attachmentsRoot, 42, json(), recent, NOW); + + assertThat(result).isEqualTo(AttachmentCleaner.Result.CLEANED); + assertThat(uploaded.exists()).isTrue(); + } + + @Test + public void fileInsideTheGracePeriodIsKeptEvenWhenNotRegistered() throws Exception { + // The process that uploaded it died before the save: only its age protects it. + File young = write("1731000000006_654321.jpg", "young"); + young.setLastModified(NOW - AttachmentCleaner.GRACE_PERIOD_MS / 2); + + AttachmentCleaner.cleanup(attachmentsRoot, 42, json(), new HashSet<>(), NOW); + + assertThat(young.exists()).isTrue(); + } + + @Test + public void orphanOlderThanTheGracePeriodIsStillDeleted() throws Exception { + File old = write("1731000000007_111111.jpg", "old orphan"); + old.setLastModified(NOW - AttachmentCleaner.GRACE_PERIOD_MS - 60_000); + + AttachmentCleaner.cleanup(attachmentsRoot, 42, json(), new HashSet<>(), NOW); + + assertThat(old.exists()).isFalse(); + } + + @Test + public void uploadStopsBeingProtectedOnceASavedNoteReferencesIt() throws Exception { + String name = "1731000000008_222222.jpg"; + File file = write(name, "inserted"); + file.setLastModified(NOW - AttachmentCleaner.GRACE_PERIOD_MS - 60_000); + RecentAttachmentUploads.register(42, name); + + AttachmentCleaner.cleanup( + attachmentsRoot, + 42, + json("editorjs://attachments/note_42/" + name), + RecentAttachmentUploads.protectedNames(42), + NOW); + + assertThat(file.exists()).isTrue(); + assertThat(RecentAttachmentUploads.protectedNames(42)).isEmpty(); + + // The user deletes the block later: from then on the file is a genuine orphan. + AttachmentCleaner.cleanup( + attachmentsRoot, 42, json(), RecentAttachmentUploads.protectedNames(42), NOW); + + assertThat(file.exists()).isFalse(); + } + + @After + public void forgetRecentUploads() { + RecentAttachmentUploads.clear(); + } + private File write(String name, String content) throws Exception { File file = new File(noteFolder, name); Files.write(file.toPath(), content.getBytes(StandardCharsets.UTF_8)); diff --git a/app/src/test/java/com/pasich/mynotes/extendedEditor/attach/EditorAttachmentBlocksTest.java b/app/src/test/java/com/pasich/mynotes/extendedEditor/attach/EditorAttachmentBlocksTest.java index e26908ff..4c2deed0 100644 --- a/app/src/test/java/com/pasich/mynotes/extendedEditor/attach/EditorAttachmentBlocksTest.java +++ b/app/src/test/java/com/pasich/mynotes/extendedEditor/attach/EditorAttachmentBlocksTest.java @@ -81,4 +81,35 @@ public void findsTheFileOfABlockInEitherShape() { assertThat(EditorAttachmentBlocks.findFile(DOCUMENT, "p")).isNull(); assertThat(EditorAttachmentBlocks.findFile(DOCUMENT, "missing")).isNull(); } + + @Test + public void keepOnlyFiles_dropsBlocksWhoseFileIsGoneAndTrimsLists() { + EditorAttachmentBlocks.Filtered filtered = + EditorAttachmentBlocks.keepOnlyFiles( + DOCUMENT, + java.util.Set.of( + "editorjs://attachments/note_5/one.pdf", + "editorjs://attachments/note_5/three.png")); + + assertThat(filtered.removed).isEqualTo(2); + assertThat(EditorAttachmentBlocks.fileUrls(filtered.valueJson)) + .containsExactly( + "editorjs://attachments/note_5/one.pdf", + "editorjs://attachments/note_5/three.png") + .inOrder(); + // The video block lost its only file and goes; text blocks are untouched. + assertThat(filtered.valueJson).doesNotContain("\"id\":\"v\""); + assertThat(filtered.valueJson).contains("hello there"); + } + + @Test + public void keepOnlyFiles_returnsTheDocumentVerbatimWhenEverythingStays() { + EditorAttachmentBlocks.Filtered filtered = + EditorAttachmentBlocks.keepOnlyFiles( + DOCUMENT, + new java.util.HashSet<>(EditorAttachmentBlocks.fileUrls(DOCUMENT))); + + assertThat(filtered.removed).isEqualTo(0); + assertThat(filtered.valueJson).isSameInstanceAs(DOCUMENT); + } } diff --git a/app/src/test/java/com/pasich/mynotes/extendedEditor/models/PickedFileTest.java b/app/src/test/java/com/pasich/mynotes/extendedEditor/models/PickedFileTest.java new file mode 100644 index 00000000..102f1ee1 --- /dev/null +++ b/app/src/test/java/com/pasich/mynotes/extendedEditor/models/PickedFileTest.java @@ -0,0 +1,42 @@ +package com.pasich.mynotes.extendedEditor.models; + +import static com.google.common.truth.Truth.assertThat; +import static org.mockito.Mockito.mock; + +import android.net.Uri; +import org.junit.Test; + +/** The page's upload request is matched to the picked file Android already holds. */ +public class PickedFileTest { + + private final Uri uri = mock(Uri.class); + + @Test + public void matchesByDisplayName() { + PickedFile picked = new PickedFile(uri, "IMG_2041.jpg", 1_204_000); + + assertThat(picked.matches("IMG_2041.jpg", 0)).isTrue(); + } + + @Test + public void matchesBySizeWhenTheNameDiffers() { + // Some providers report a different name to the page than to the resolver. + PickedFile picked = new PickedFile(uri, "image:1834", 1_204_000); + + assertThat(picked.matches("IMG_2041.jpg", 1_204_000)).isTrue(); + } + + @Test + public void doesNotMatchAnotherFile() { + PickedFile picked = new PickedFile(uri, "IMG_2041.jpg", 1_204_000); + + assertThat(picked.matches("pasted.png", 5_000)).isFalse(); + } + + @Test + public void unknownSizeNeverMatchesBySize() { + PickedFile picked = new PickedFile(uri, null, -1); + + assertThat(picked.matches("pasted.png", -1)).isFalse(); + } +} diff --git a/app/src/test/java/com/pasich/mynotes/extendedEditor/utils/EditorJSInterfaceFinishTest.java b/app/src/test/java/com/pasich/mynotes/extendedEditor/utils/EditorJSInterfaceFinishTest.java new file mode 100644 index 00000000..c3b3fe6e --- /dev/null +++ b/app/src/test/java/com/pasich/mynotes/extendedEditor/utils/EditorJSInterfaceFinishTest.java @@ -0,0 +1,119 @@ +package com.pasich.mynotes.extendedEditor.utils; + +import static com.google.common.truth.Truth.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.contains; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.robolectric.Shadows.shadowOf; + +import android.content.Context; +import android.os.Looper; +import android.webkit.WebView; +import com.pasich.mynotes.extendedEditor.models.SettingsEditorJsBridge; +import java.time.Duration; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.RuntimeEnvironment; +import org.robolectric.annotation.Config; + +/** + * The extended editor keeps its WebView until the page has handed over its last edit, and never + * sends anything to a destroyed WebView. + */ +@RunWith(RobolectricTestRunner.class) +@Config(sdk = 35) +public class EditorJSInterfaceFinishTest { + + private WebView webView; + private EditorJSInterface bridge; + private final AtomicInteger destroyed = new AtomicInteger(); + + @Before + public void setUp() { + Context context = RuntimeEnvironment.getApplication(); + webView = mock(WebView.class); + bridge = + new EditorJSInterface( + mock(EditorJSInterface.EditorListener.class), + webView, + context, + new SettingsEditorJsBridge(false)); + } + + private void destroyWebView() { + destroyed.incrementAndGet(); + bridge.release(); + } + + private static void idleFor(long ms) { + shadowOf(Looper.getMainLooper()).idleFor(Duration.ofMillis(ms)); + } + + @Test + public void finishing_waitsForThePageToAnswer() { + bridge.finishPage(true, this::destroyWebView); + + verify(webView).evaluateJavascript(contains("finishPage(true)"), any()); + idleFor(EditorJSInterface.FINISH_TIMEOUT_MS / 2); + assertThat(destroyed.get()).isEqualTo(0); + + // The page answers on the bridge thread once its last edit was sent. + bridge.onPageFinished(); + idleFor(0); + assertThat(destroyed.get()).isEqualTo(1); + + idleFor(EditorJSInterface.FINISH_TIMEOUT_MS * 2); + assertThat(destroyed.get()).isEqualTo(1); + } + + @Test + public void aPageThatNeverAnswers_isDestroyedAfterTheTimeout() { + bridge.finishPage(false, this::destroyWebView); + + idleFor(EditorJSInterface.FINISH_TIMEOUT_MS + 10); + assertThat(destroyed.get()).isEqualTo(1); + + bridge.onPageFinished(); + idleFor(10); + assertThat(destroyed.get()).isEqualTo(1); + } + + @Test + public void afterTheWebViewIsDestroyed_nothingReachesIt() throws Exception { + bridge.release(); + + bridge.requestFlush(); + bridge.undo(); + bridge.toggleReadMode(); + // A flush asked for from another thread just before the WebView went. + Thread other = new Thread(bridge::requestFlush); + other.start(); + other.join(); + idleFor(10); + + verify(webView, never()).evaluateJavascript(any(), any()); + } + + @Test + public void aFlushPostedBeforeTheWebViewWent_isDroppedWhenItRuns() throws Exception { + Thread other = new Thread(bridge::requestFlush); + other.start(); + other.join(); + bridge.release(); + idleFor(10); + + verify(webView, never()).evaluateJavascript(any(), any()); + } + + @Test + public void finishingAnAlreadyDestroyedPage_runsAtOnce() { + bridge.release(); + bridge.finishPage(false, destroyed::incrementAndGet); + assertThat(destroyed.get()).isEqualTo(1); + } +} diff --git a/app/src/test/java/com/pasich/mynotes/extendedEditor/utils/ExtendedViewStateJsonTest.java b/app/src/test/java/com/pasich/mynotes/extendedEditor/utils/ExtendedViewStateJsonTest.java new file mode 100644 index 00000000..01baa859 --- /dev/null +++ b/app/src/test/java/com/pasich/mynotes/extendedEditor/utils/ExtendedViewStateJsonTest.java @@ -0,0 +1,114 @@ +package com.pasich.mynotes.extendedEditor.utils; + +import static com.google.common.truth.Truth.assertThat; + +import com.google.gson.JsonObject; +import com.pasich.mynotes.utils.editor.NoteViewState; +import com.pasich.mynotes.utils.editor.PositionRestorer; +import java.util.Arrays; +import org.junit.Test; + +public class ExtendedViewStateJsonTest { + + @Test + public void fromPage_readsTheReportedPosition() { + NoteViewState.Extended state = + ExtendedViewStateJson.fromPage( + "{\"scrollTop\":840,\"ratio\":0.42,\"topId\":\"b2\",\"topIndex\":3," + + "\"topOffset\":-18,\"caret\":{\"id\":\"b5\",\"input\":1," + + "\"offset\":27}}"); + + assertThat(state).isNotNull(); + assertThat(state.scrollTop).isEqualTo(840); + assertThat(state.scrollRatio).isWithin(1e-4f).of(0.42f); + assertThat(state.topBlockId).isEqualTo("b2"); + assertThat(state.topBlockIndex).isEqualTo(3); + assertThat(state.topOffsetPx).isEqualTo(-18); + assertThat(state.caretBlockId).isEqualTo("b5"); + assertThat(state.caretInput).isEqualTo(1); + assertThat(state.caretOffset).isEqualTo(27); + } + + @Test + public void fromPage_withoutCaretOrTopBlock_leavesThemUnset() { + NoteViewState.Extended state = + ExtendedViewStateJson.fromPage( + "{\"scrollTop\":0,\"ratio\":0,\"topId\":null,\"topIndex\":-1," + + "\"topOffset\":0,\"caret\":null}"); + + assertThat(state).isNotNull(); + assertThat(state.caretBlockId).isNull(); + assertThat(state.topBlockId).isNull(); + assertThat(state.topBlockIndex).isEqualTo(-1); + } + + @Test + public void fromPage_rejectsGarbage() { + assertThat(ExtendedViewStateJson.fromPage(null)).isNull(); + assertThat(ExtendedViewStateJson.fromPage("")).isNull(); + assertThat(ExtendedViewStateJson.fromPage("not json")).isNull(); + assertThat(ExtendedViewStateJson.fromPage("[1,2]")).isNull(); + } + + @Test + public void blockIds_readsIdsInOrder_andKeepsThePlaceOfBlocksWithoutOne() { + String json = + "[{\"id\":\"a\",\"type\":\"paragraph\",\"data\":{}}," + + "{\"type\":\"paragraph\",\"data\":{}}," + + "{\"id\":\"c\",\"type\":\"header\",\"data\":{}}]"; + + assertThat(ExtendedViewStateJson.blockIds(json)).containsExactly("a", "", "c").inOrder(); + } + + @Test + public void blockIds_acceptsAWholeEditorDocument() { + assertThat(ExtendedViewStateJson.blockIds("{\"blocks\":[{\"id\":\"x\"}]}")) + .containsExactly("x"); + } + + @Test + public void blockIds_ofNothingIsEmpty() { + assertThat(ExtendedViewStateJson.blockIds(null)).isEmpty(); + assertThat(ExtendedViewStateJson.blockIds(" ")).isEmpty(); + assertThat(ExtendedViewStateJson.blockIds("{broken")).isEmpty(); + } + + @Test + public void toPage_carriesTheTargetForTheRuntime() { + NoteViewState.Extended saved = new NoteViewState.Extended(); + saved.caretBlockId = "c"; + saved.caretOffset = 4; + saved.topBlockId = "b"; + saved.topBlockIndex = 1; + saved.topOffsetPx = 12; + PositionRestorer.ExtendedTarget target = + PositionRestorer.restoreExtended(saved, Arrays.asList("a", "b", "c")); + + JsonObject page = ExtendedViewStateJson.toPage(target); + + assertThat(page.get("caretId").getAsString()).isEqualTo("c"); + assertThat(page.get("caretOffset").getAsInt()).isEqualTo(4); + assertThat(page.get("topId").getAsString()).isEqualTo("b"); + assertThat(page.get("topIndex").getAsInt()).isEqualTo(1); + assertThat(page.get("topOffset").getAsInt()).isEqualTo(12); + } + + @Test + public void fromScriptResult_unwrapsTheStringThePageReturned() { + String json = "{\"scrollTop\":250,\"topIndex\":2}"; + String result = new com.google.gson.JsonPrimitive(json).toString(); + + assertThat(ExtendedViewStateJson.fromScriptResult(result)).isEqualTo(json); + assertThat(ExtendedViewStateJson.fromPage(ExtendedViewStateJson.fromScriptResult(result))) + .isNotNull(); + } + + @Test + public void fromScriptResult_withoutAPosition_isNull() { + assertThat(ExtendedViewStateJson.fromScriptResult(null)).isNull(); + assertThat(ExtendedViewStateJson.fromScriptResult("null")).isNull(); + assertThat(ExtendedViewStateJson.fromScriptResult("\"\"")).isNull(); + assertThat(ExtendedViewStateJson.fromScriptResult("{\"a\":1}")).isNull(); + assertThat(ExtendedViewStateJson.fromScriptResult("not json")).isNull(); + } +} diff --git a/app/src/test/java/com/pasich/mynotes/presenter/MainPresenterTest.java b/app/src/test/java/com/pasich/mynotes/presenter/MainPresenterTest.java index af74a294..52924f35 100644 --- a/app/src/test/java/com/pasich/mynotes/presenter/MainPresenterTest.java +++ b/app/src/test/java/com/pasich/mynotes/presenter/MainPresenterTest.java @@ -1,20 +1,41 @@ package com.pasich.mynotes.presenter; +import static com.google.common.truth.Truth.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.atLeastOnce; +import static org.mockito.Mockito.clearInvocations; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import com.pasich.mynotes.base.BasePresenterTest; import com.pasich.mynotes.data.DataManager; import com.pasich.mynotes.data.model.Note; +import com.pasich.mynotes.data.model.Tag; import com.pasich.mynotes.ui.contract.MainContract; import com.pasich.mynotes.ui.presenter.MainPresenter; +import com.pasich.mynotes.ui.state.MainViewState; +import com.pasich.mynotes.utils.constants.settings.SortParam; +import com.pasich.mynotes.utils.managers.SystemTagsManager; +import com.pasich.mynotes.utils.rx.SchedulerProvider; +import com.pasich.mynotes.utils.search.SearchHit; import io.reactivex.Flowable; +import io.reactivex.Scheduler; import io.reactivex.disposables.CompositeDisposable; +import io.reactivex.processors.BehaviorProcessor; +import io.reactivex.schedulers.Schedulers; +import io.reactivex.schedulers.TestScheduler; +import java.util.ArrayList; +import java.util.Arrays; import java.util.Collections; import java.util.List; +import java.util.concurrent.TimeUnit; import org.junit.Before; import org.junit.Test; +import org.mockito.ArgumentCaptor; import org.mockito.Mock; public class MainPresenterTest extends BasePresenterTest { @@ -85,4 +106,304 @@ public void detachView_afterAsyncCallback_doesNotThrowNPE() { presenter.detachView(); // If we get here without exception the test passes } + + // ---- Sync regressions (#170) ------------------------------------------------------------- + + private final TestScheduler debounceClock = new TestScheduler(); + private BehaviorProcessor> tagsDb; + private BehaviorProcessor> notesDb; + + /** Presenter wired to processors standing in for Room, with a controllable debounce clock. */ + private MainPresenter syncPresenter(List tags, List notes) { + tagsDb = BehaviorProcessor.createDefault(tags); + notesDb = BehaviorProcessor.createDefault(notes); + when(mockDataManager.getTags()).thenReturn(tagsDb); + when(mockDataManager.getNotes()).thenReturn(notesDb); + when(mockDataManager.getSortParam()).thenReturn(SortParam.DataSort); + SchedulerProvider provider = + new SchedulerProvider() { + @Override + public Scheduler ui() { + return Schedulers.trampoline(); + } + + @Override + public Scheduler computation() { + return debounceClock; + } + + @Override + public Scheduler io() { + return Schedulers.trampoline(); + } + }; + MainPresenter p = new MainPresenter(provider, new CompositeDisposable(), mockDataManager); + p.attachView(mockView); + p.viewIsReady(); + settle(); + return p; + } + + private void settle() { + debounceClock.advanceTimeBy(1, TimeUnit.SECONDS); + } + + private MainViewState lastRendered() { + ArgumentCaptor captor = ArgumentCaptor.forClass(MainViewState.class); + verify(mockView, atLeastOnce()).render(captor.capture()); + List all = captor.getAllValues(); + return all.get(all.size() - 1); + } + + private static Tag userTag(long id, String name) { + Tag t = new Tag().create(name); + t.id = id; + return t; + } + + private static List tagsWith(Tag... user) { + List list = new ArrayList<>(Arrays.asList(user)); + list.addAll(SystemTagsManager.getSystemTags()); + return list; + } + + private static Note note(int id, String title, long date, String tag) { + Note n = new Note().create(title, "body " + id, date, tag); + n.setId(id); + return n; + } + + private static List ids(MainViewState state) { + List ids = new ArrayList<>(); + for (Note n : state.notes()) ids.add(n.getId()); + return ids; + } + + private static Tag selectedChip(MainViewState state) { + for (Tag t : state.tags()) if (t.getSelected()) return t; + return null; + } + + @Test + public void syncRenamesSelectedTag_categoryKeepsItsNotes() { + MainPresenter p = + syncPresenter( + tagsWith(userTag(5, "work")), + Arrays.asList(note(1, "a", 10, "work"), note(2, "b", 20, ""))); + p.onTagSelected(userTag(5, "work")); + settle(); + assertThat(ids(lastRendered())).containsExactly(1); + + // A sync renames the tag on another device; the notes follow the new name. + tagsDb.onNext(tagsWith(userTag(5, "job"))); + notesDb.onNext(Arrays.asList(note(1, "a", 10, "job"), note(2, "b", 20, ""))); + settle(); + + MainViewState state = lastRendered(); + assertThat(state.selectedTag().getNameTag()).isEqualTo("job"); + assertThat(ids(state)).containsExactly(1); + assertThat(selectedChip(state).getId()).isEqualTo(5L); + } + + @Test + public void syncDeletesSelectedTag_fallsBackToAllNotes() { + MainPresenter p = + syncPresenter( + tagsWith(userTag(5, "work")), + Arrays.asList(note(1, "a", 10, "work"), note(2, "b", 20, ""))); + p.onTagSelected(userTag(5, "work")); + settle(); + + tagsDb.onNext(tagsWith()); + notesDb.onNext(Arrays.asList(note(1, "a", 10, ""), note(2, "b", 20, ""))); + settle(); + + MainViewState state = lastRendered(); + assertThat(SystemTagsManager.isAllNotesTag(state.selectedTag())).isTrue(); + assertThat(SystemTagsManager.isAllNotesTag(selectedChip(state))).isTrue(); + assertThat(ids(state)).containsExactly(2, 1).inOrder(); + } + + @Test + public void syncReplacesSelectedTagWithSameName_keepsCategory() { + MainPresenter p = + syncPresenter( + tagsWith(userTag(5, "work")), + Arrays.asList(note(1, "a", 10, "work"), note(2, "b", 20, ""))); + p.onTagSelected(userTag(5, "work")); + settle(); + + // Dedupe on sync: local row 5 is dropped in favour of the remote row 9 with the same name. + tagsDb.onNext(tagsWith(userTag(9, "work"))); + settle(); + + MainViewState state = lastRendered(); + assertThat(state.selectedTag().getId()).isEqualTo(9L); + assertThat(ids(state)).containsExactly(1); + assertThat(selectedChip(state).getId()).isEqualTo(9L); + } + + @Test + public void syncInsertsUpdatesAndDeletes_listFollows() { + syncPresenter(tagsWith(), Arrays.asList(note(1, "a", 10, ""), note(2, "b", 20, ""))); + assertThat(ids(lastRendered())).containsExactly(2, 1).inOrder(); + + notesDb.onNext( + Arrays.asList(note(1, "a", 10, ""), note(2, "b", 20, ""), note(3, "c", 30, ""))); + settle(); + assertThat(ids(lastRendered())).containsExactly(3, 2, 1).inOrder(); + + notesDb.onNext( + Arrays.asList( + note(1, "a edited", 40, ""), note(2, "b", 20, ""), note(3, "c", 30, ""))); + settle(); + MainViewState updated = lastRendered(); + assertThat(ids(updated)).containsExactly(1, 3, 2).inOrder(); + assertThat(updated.notes().get(0).getTitle()).isEqualTo("a edited"); + + notesDb.onNext(Arrays.asList(note(1, "a edited", 40, ""), note(2, "b", 20, ""))); + settle(); + assertThat(ids(lastRendered())).containsExactly(1, 2).inOrder(); + } + + @Test + public void identicalReEmission_doesNotRenderAgain() { + syncPresenter(tagsWith(userTag(5, "work")), Arrays.asList(note(1, "a", 10, "work"))); + clearInvocations(mockView); + + // A no-op sync: Room re-emits fresh but equal rows. + tagsDb.onNext(tagsWith(userTag(5, "work"))); + notesDb.onNext(Arrays.asList(note(1, "a", 10, "work"))); + settle(); + + verify(mockView, never()).render(org.mockito.Mockito.any()); + } + + @Test + public void tagsAndNotesEmittedTogether_renderOnce() { + syncPresenter(tagsWith(), Collections.emptyList()); + clearInvocations(mockView); + + tagsDb.onNext(tagsWith(userTag(5, "work"))); + debounceClock.advanceTimeBy(5, TimeUnit.MILLISECONDS); + notesDb.onNext(Arrays.asList(note(1, "a", 10, "work"))); + settle(); + + verify(mockView, times(1)).render(org.mockito.Mockito.any()); + assertThat(ids(lastRendered())).containsExactly(1); + } + + // ---- Search ranking (#175) ---------------------------------------------------------------- + + @SuppressWarnings("unchecked") + private List lastSearch(boolean expectTitlesOnly) { + ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class); + verify(mockView, atLeastOnce()).renderSearch(captor.capture(), eq(expectTitlesOnly)); + List> all = captor.getAllValues(); + return all.get(all.size() - 1); + } + + @Test + public void search_ranksExactTitleFirstAfterDebounce() { + MainPresenter p = + syncPresenter( + tagsWith(), + Arrays.asList( + new Note().create("Weekly", "review the PR", 30, ""), + new Note().create("PR", "", 10, ""))); + clearInvocations(mockView); + + p.updateSearchQuery("PR "); + verify(mockView, never()).renderSearch(any(), anyBoolean()); + settle(); + + List hits = lastSearch(false); + assertThat(hits).hasSize(2); + assertThat(hits.get(0).note().getTitle()).isEqualTo("PR"); + assertThat(hits.get(0).kind()).isEqualTo(SearchHit.MatchKind.EXACT_TITLE); + } + + @Test + public void search_oneCharacterAsksToKeepTyping() { + MainPresenter p = + syncPresenter( + tagsWith(), Arrays.asList(new Note().create("Notes", "q is here", 30, ""))); + clearInvocations(mockView); + + p.updateSearchQuery("q"); + settle(); + + assertThat(lastSearch(true)).isEmpty(); + } + + // ---- Custom order (#178) ----------------------------------------------------------------- + + private static Note placed(int id, long position, boolean pinned) { + Note n = note(id, "n" + id, id * 10L, ""); + n.setCustomPosition(position); + n.setPinned(pinned); + return n; + } + + @Test + public void customOrder_putsPinnedFirstThenTheUsersOrder() { + when(mockDataManager.getSortParam()).thenReturn(SortParam.Custom); + List notes = + Arrays.asList( + placed(1, 3072, false), + placed(2, 1024, true), + placed(3, 5120, false), + placed(4, 4096, true), + placed(5, 2048, false)); + MainPresenter p = syncPresenter(tagsWith(), notes); + p.onSortChanged(SortParam.Custom); + settle(); + + // Pinned notes keep their own section, each section in the user's order; the edit date + // plays no part. + assertThat(ids(lastRendered())).containsExactly(4, 2, 3, 1, 5).inOrder(); + assertThat(p.isCustomOrder()).isTrue(); + } + + @Test + public void customOrder_switchingAwayAndBackKeepsTheOrder() { + MainPresenter p = + syncPresenter( + tagsWith(), + Arrays.asList( + placed(1, 3072, false), + placed(2, 1024, false), + placed(3, 2048, false))); + p.onSortChanged(SortParam.Custom); + settle(); + assertThat(ids(lastRendered())).containsExactly(1, 3, 2).inOrder(); + + p.onSortChanged(SortParam.DataSort); + settle(); + assertThat(ids(lastRendered())).containsExactly(3, 2, 1).inOrder(); + assertThat(p.isCustomOrder()).isFalse(); + + p.onSortChanged(SortParam.Custom); + settle(); + assertThat(ids(lastRendered())).containsExactly(1, 3, 2).inOrder(); + } + + @Test + public void customOrder_equalPositionsFallBackToTheNewerNote() { + assertThat( + MainPresenter.comparatorFor(SortParam.Custom) + .compare(placed(1, 1024, false), placed(2, 1024, false))) + .isGreaterThan(0); + } + + @Test + public void moveNoteInCustomOrder_passesTheNeighboursToTheData() { + when(mockDataManager.moveNoteInCustomOrder(7, 3, null)) + .thenReturn(io.reactivex.Completable.complete()); + MainPresenter p = syncPresenter(tagsWith(), Arrays.asList(placed(7, 1024, false))); + + p.moveNoteInCustomOrder(7, 3, null); + + verify(mockDataManager).moveNoteInCustomOrder(7, 3, null); + } } diff --git a/app/src/test/java/com/pasich/mynotes/presenter/NotePresenterEditHistoryTest.java b/app/src/test/java/com/pasich/mynotes/presenter/NotePresenterEditHistoryTest.java new file mode 100644 index 00000000..9ce3fc37 --- /dev/null +++ b/app/src/test/java/com/pasich/mynotes/presenter/NotePresenterEditHistoryTest.java @@ -0,0 +1,144 @@ +package com.pasich.mynotes.presenter; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.pasich.mynotes.base.BasePresenterTest; +import com.pasich.mynotes.data.DataManager; +import com.pasich.mynotes.data.model.Note; +import com.pasich.mynotes.ui.contract.NoteContract; +import com.pasich.mynotes.ui.presenter.NotePresenter; +import com.pasich.mynotes.utils.constants.AutoSave; +import com.pasich.mynotes.utils.rx.SchedulerProvider; +import io.reactivex.Completable; +import io.reactivex.Scheduler; +import io.reactivex.Single; +import io.reactivex.disposables.CompositeDisposable; +import io.reactivex.schedulers.Schedulers; +import io.reactivex.schedulers.TestScheduler; +import java.util.Date; +import java.util.concurrent.TimeUnit; +import org.junit.Before; +import org.junit.Test; +import org.mockito.InOrder; +import org.mockito.Mock; + +/** + * The editor's undo history belongs to the note on screen: it starts again whenever a note is put + * into the editor, and saving never touches it. + */ +public class NotePresenterEditHistoryTest extends BasePresenterTest { + + @Mock DataManager dataManager; + @Mock NoteContract.view view; + + private final TestScheduler debounceClock = new TestScheduler(); + private NotePresenter presenter; + + @Before + public void setUp() { + initMocks(this); + when(dataManager.updateNote(any())).thenReturn(Completable.complete()); + presenter = new NotePresenter(provider(), new CompositeDisposable(), dataManager); + presenter.attachView(view); + presenter.setNewNoteKey(false); + presenter.setIdKey(1L); + when(dataManager.getNoteForId(1L)).thenReturn(Single.just(note(1, "stored text"))); + } + + private static Note note(int id, String value) { + Note note = new Note().create("Title", value, new Date().getTime(), ""); + note.setId(id); + note.setValueJson(""); + note.setAttachments("[]"); + return note; + } + + private SchedulerProvider provider() { + return new SchedulerProvider() { + @Override + public Scheduler ui() { + return Schedulers.trampoline(); + } + + @Override + public Scheduler computation() { + return debounceClock; + } + + @Override + public Scheduler io() { + return Schedulers.trampoline(); + } + }; + } + + @Test + public void loadingANoteStartsTheHistoryAgainAfterTheNoteIsShown() { + presenter.loadingData(1L); + + InOrder order = inOrder(view); + order.verify(view).loadingNote(any()); + order.verify(view).resetEditHistory(); + } + + @Test + public void autosaveDoesNotTouchTheHistory() { + presenter.loadingData(1L); + + presenter.simpleNoteChange(null, "typed", false); + debounceClock.advanceTimeBy(AutoSave.AUTO_SAVE_DELAY, TimeUnit.MILLISECONDS); + presenter.simpleNoteChange(null, "typed more", false); + presenter.flushPending(); + debounceClock.advanceTimeBy(5, TimeUnit.SECONDS); + + verify(dataManager, times(2)).updateNote(any()); + verify(view, times(1)).resetEditHistory(); + } + + @Test + public void extendedAutosaveDoesNotTouchTheHistory() { + presenter.setExtendedEditor(true); + presenter.loadingData(1L); + + presenter.extendedNoteChange( + null, "[{\"id\":\"a\",\"type\":\"paragraph\",\"data\":{\"text\":\"x\"}}]"); + debounceClock.advanceTimeBy(AutoSave.AUTO_SAVE_DELAY, TimeUnit.MILLISECONDS); + + verify(dataManager, times(1)).updateNote(any()); + verify(view, times(1)).resetEditHistory(); + } + + @Test + public void restoringAVersionStartsTheHistoryAgain() { + presenter.loadingData(1L); + + presenter.reloadNote(); + + verify(view, times(2)).resetEditHistory(); + } + + @Test + public void aCopyStartsItsOwnHistory() { + presenter.loadingData(1L); + when(dataManager.copyNote(any())).thenReturn(Single.just(2L)); + when(dataManager.getNoteForId(2L)).thenReturn(Single.just(note(2, "stored text"))); + + presenter.copyNoteRequest(); + + verify(view, times(2)).resetEditHistory(); + } + + @Test + public void aNoteThatFailsToLoadLeavesTheHistoryAlone() { + when(dataManager.getNoteForId(1L)).thenReturn(Single.error(new RuntimeException("db"))); + + presenter.loadingData(1L); + + verify(view, never()).resetEditHistory(); + } +} diff --git a/app/src/test/java/com/pasich/mynotes/presenter/NotePresenterFlushTest.java b/app/src/test/java/com/pasich/mynotes/presenter/NotePresenterFlushTest.java new file mode 100644 index 00000000..76bb93af --- /dev/null +++ b/app/src/test/java/com/pasich/mynotes/presenter/NotePresenterFlushTest.java @@ -0,0 +1,206 @@ +package com.pasich.mynotes.presenter; + +import static com.google.common.truth.Truth.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.pasich.mynotes.base.BasePresenterTest; +import com.pasich.mynotes.data.DataManager; +import com.pasich.mynotes.data.model.Note; +import com.pasich.mynotes.ui.contract.NoteContract; +import com.pasich.mynotes.ui.presenter.NotePresenter; +import com.pasich.mynotes.utils.constants.AutoSave; +import com.pasich.mynotes.utils.rx.SchedulerProvider; +import io.reactivex.Completable; +import io.reactivex.Scheduler; +import io.reactivex.Single; +import io.reactivex.disposables.CompositeDisposable; +import io.reactivex.schedulers.Schedulers; +import io.reactivex.schedulers.TestScheduler; +import java.util.ArrayList; +import java.util.Date; +import java.util.List; +import java.util.concurrent.TimeUnit; +import org.junit.Before; +import org.junit.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; + +/** + * Leaving the editor must never lose what was typed in the autosave's two-second window, and the + * extended editor's document is parsed when it is saved rather than on every change. + */ +public class NotePresenterFlushTest extends BasePresenterTest { + + private static final String DOC_HELLO = + "[{\"id\":\"a1\",\"type\":\"paragraph\",\"data\":{\"text\":\"hello\"}}]"; + private static final String DOC_EMPTY = + "[{\"id\":\"a1\",\"type\":\"paragraph\",\"data\":{\"text\":\"\"}}]"; + + @Mock DataManager dataManager; + @Mock NoteContract.view view; + + private final TestScheduler debounceClock = new TestScheduler(); + private final TestScheduler ioScheduler = new TestScheduler(); + private final List writtenValues = new ArrayList<>(); + private NotePresenter presenter; + private Note note; + + @Before + public void setUp() { + initMocks(this); + when(dataManager.updateNote(any())) + .thenAnswer( + invocation -> { + Note written = invocation.getArgument(0); + return Completable.fromAction( + () -> writtenValues.add(written.getValue())); + }); + presenter = new NotePresenter(provider(), new CompositeDisposable(), dataManager); + presenter.attachView(view); + + note = new Note().create("Title", "stored text", new Date().getTime(), ""); + note.setValueJson(""); + note.setAttachments("[]"); + when(dataManager.getNoteForId(1L)).thenReturn(Single.just(note)); + presenter.setNewNoteKey(false); + presenter.setIdKey(1L); + // Open the note the way the screen does, so the presenter knows what is stored. + presenter.loadingData(1L); + ioScheduler.triggerActions(); + } + + private SchedulerProvider provider() { + return new SchedulerProvider() { + @Override + public Scheduler ui() { + return Schedulers.trampoline(); + } + + @Override + public Scheduler computation() { + return debounceClock; + } + + @Override + public Scheduler io() { + return ioScheduler; + } + }; + } + + @Test + public void editInsideTheDebounceIsNotWrittenUntilThePause() { + presenter.simpleNoteChange(null, "typed just now", false); + ioScheduler.triggerActions(); + + assertThat(writtenValues).isEmpty(); + assertThat(presenter.hasUnsavedChanges()).isTrue(); + } + + @Test + public void flushWritesTheEditStillInsideTheDebounce() { + presenter.simpleNoteChange(null, "typed just now", false); + + presenter.flushPending(); + ioScheduler.triggerActions(); + + assertThat(writtenValues).containsExactly("typed just now"); + assertThat(presenter.hasUnsavedChanges()).isFalse(); + } + + @Test + public void debounceAfterAFlushDoesNotWriteTheSameTextAgain() { + presenter.simpleNoteChange(null, "typed just now", false); + presenter.flushPending(); + ioScheduler.triggerActions(); + + debounceClock.advanceTimeBy(AutoSave.AUTO_SAVE_DELAY, TimeUnit.MILLISECONDS); + ioScheduler.triggerActions(); + + assertThat(writtenValues).containsExactly("typed just now"); + } + + @Test + public void flushWithNothingChangedWritesNothing() { + presenter.flushPending(); + ioScheduler.triggerActions(); + + verify(dataManager, never()).updateNote(any()); + } + + @Test + public void flushOfAnEmptiedNoteWritesNothing() { + presenter.simpleNoteChange("", " ", false); + + presenter.flushPending(); + ioScheduler.triggerActions(); + + verify(dataManager, never()).updateNote(any()); + } + + @Test + public void flushIsNotCancelledWhenTheScreenIsDestroyedRightAfterIt() { + // Rotation: onStop flushes, onDestroy detaches before the write has run. + presenter.simpleNoteChange(null, "typed before rotating", false); + presenter.flushPending(); + presenter.detachView(); + + ioScheduler.triggerActions(); + + assertThat(writtenValues).containsExactly("typed before rotating"); + } + + @Test + public void debounceStillSavesOnceTypingPauses() { + presenter.simpleNoteChange(null, "a", false); + presenter.simpleNoteChange(null, "ab", false); + presenter.simpleNoteChange(null, "abc", false); + + debounceClock.advanceTimeBy(AutoSave.AUTO_SAVE_DELAY, TimeUnit.MILLISECONDS); + ioScheduler.triggerActions(); + + assertThat(writtenValues).containsExactly("abc"); + } + + @Test + public void extendedDocumentIsParsedWhenItIsSaved() { + presenter.setExtendedEditor(true); + + presenter.extendedNoteChange(null, DOC_HELLO); + presenter.flushPending(); + ioScheduler.triggerActions(); + + ArgumentCaptor written = ArgumentCaptor.forClass(Note.class); + verify(dataManager, times(1)).updateNote(written.capture()); + assertThat(written.getValue().getValueJson()).isEqualTo(DOC_HELLO); + } + + @Test + public void extendedChangeWaitsForThePauseLikeTyping() { + presenter.setExtendedEditor(true); + + presenter.extendedNoteChange(null, DOC_HELLO); + ioScheduler.triggerActions(); + + verify(dataManager, never()).updateNote(any()); + assertThat(presenter.hasUnsavedChanges()).isTrue(); + } + + @Test + public void emptiedExtendedNoteNeitherSavesNorCleansAttachments() { + // Cleaning against a list that was never written would delete files the stored note + // still references. + presenter.setExtendedEditor(true); + + presenter.extendedNoteChange("", DOC_EMPTY); + debounceClock.advanceTimeBy(AutoSave.AUTO_SAVE_DELAY, TimeUnit.MILLISECONDS); + ioScheduler.triggerActions(); + + verify(dataManager, never()).updateNote(any()); + verify(view, never()).runAttachmentsCleanup(any()); + } +} diff --git a/app/src/test/java/com/pasich/mynotes/ui/controllers/SelectionPanelTest.java b/app/src/test/java/com/pasich/mynotes/ui/controllers/SelectionPanelTest.java new file mode 100644 index 00000000..b70418f5 --- /dev/null +++ b/app/src/test/java/com/pasich/mynotes/ui/controllers/SelectionPanelTest.java @@ -0,0 +1,55 @@ +package com.pasich.mynotes.ui.controllers; + +import static com.google.common.truth.Truth.assertThat; + +import android.content.Context; +import android.view.ContextThemeWrapper; +import android.view.LayoutInflater; +import android.view.View; +import android.widget.FrameLayout; +import com.pasich.mynotes.R; +import com.pasich.mynotes.utils.adapters.notes.NoteAdapter; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.RuntimeEnvironment; +import org.robolectric.annotation.Config; + +@RunWith(RobolectricTestRunner.class) +@Config(sdk = 35) +public class SelectionPanelTest { + + private Context context; + private FrameLayout root; + private SelectionController controller; + + @Before + public void setUp() { + context = + new ContextThemeWrapper(RuntimeEnvironment.getApplication(), R.style.DefaultTheme); + root = new FrameLayout(context); + View panel = LayoutInflater.from(context).inflate(R.layout.action_panel, root, false); + panel.setId(R.id.actionInclude); + root.addView(panel); + controller = new SelectionController(new NoteAdapter(), root); + } + + @Test + public void inTheTrash_thereIsNoTagButton() { + controller.setPanelMode(SelectionController.Mode.RESTORE); + + assertThat(root.findViewById(R.id.action_tag_change).getVisibility()).isEqualTo(View.GONE); + assertThat(root.findViewById(R.id.action_restore).getVisibility()).isEqualTo(View.VISIBLE); + } + + @Test + public void onTheNotes_theTagButtonSaysWhatItDoes() { + controller.setPanelMode(SelectionController.Mode.NORMAL); + + View tag = root.findViewById(R.id.action_tag_change); + assertThat(tag.getVisibility()).isEqualTo(View.VISIBLE); + assertThat(tag.getContentDescription().toString()) + .isEqualTo(context.getString(R.string.selection_change_tag)); + } +} diff --git a/app/src/test/java/com/pasich/mynotes/ui/controllers/mainActivity/DrawerNavigationTest.java b/app/src/test/java/com/pasich/mynotes/ui/controllers/mainActivity/DrawerNavigationTest.java new file mode 100644 index 00000000..06de16d0 --- /dev/null +++ b/app/src/test/java/com/pasich/mynotes/ui/controllers/mainActivity/DrawerNavigationTest.java @@ -0,0 +1,57 @@ +package com.pasich.mynotes.ui.controllers.mainActivity; + +import static com.google.common.truth.Truth.assertThat; +import static org.robolectric.Shadows.shadowOf; + +import android.app.Activity; +import android.os.Looper; +import android.view.ContextThemeWrapper; +import android.view.LayoutInflater; +import androidx.core.view.GravityCompat; +import com.pasich.mynotes.R; +import com.pasich.mynotes.databinding.ActivityMainBinding; +import java.time.Duration; +import java.util.concurrent.atomic.AtomicBoolean; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.Robolectric; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; + +@RunWith(RobolectricTestRunner.class) +@Config(sdk = 35) +public class DrawerNavigationTest { + + private ActivityMainBinding binding; + private NavigationController navigation; + + @Before + public void setUp() { + Activity activity = Robolectric.buildActivity(Activity.class).setup().get(); + ContextThemeWrapper themed = new ContextThemeWrapper(activity, R.style.DefaultTheme); + binding = ActivityMainBinding.inflate(LayoutInflater.from(themed)); + activity.setContentView(binding.getRoot()); + navigation = new NavigationController(null, binding, null, null, null); + binding.drawerLayout.openDrawer(GravityCompat.START, false); + } + + @Test + public void aScreenOpenedFromTheDrawer_findsItClosedOnTheWayBack() { + AtomicBoolean opened = new AtomicBoolean(); + navigation.navigateAway(() -> opened.set(true)); + shadowOf(Looper.getMainLooper()).idleFor(Duration.ofMillis(150)); + assertThat(opened.get()).isTrue(); + + navigation.onHostStopped(); + + assertThat(binding.drawerLayout.isDrawerOpen(GravityCompat.START)).isFalse(); + } + + @Test + public void leavingTheAppOtherwise_keepsTheDrawerAsItWas() { + navigation.onHostStopped(); + + assertThat(binding.drawerLayout.isDrawerOpen(GravityCompat.START)).isTrue(); + } +} diff --git a/app/src/test/java/com/pasich/mynotes/ui/history/NoteVersionTextTest.java b/app/src/test/java/com/pasich/mynotes/ui/history/NoteVersionTextTest.java new file mode 100644 index 00000000..c3f2bf7a --- /dev/null +++ b/app/src/test/java/com/pasich/mynotes/ui/history/NoteVersionTextTest.java @@ -0,0 +1,115 @@ +package com.pasich.mynotes.ui.history; + +import static com.google.common.truth.Truth.assertThat; + +import android.content.Context; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.RuntimeEnvironment; +import org.robolectric.annotation.Config; + +@RunWith(RobolectricTestRunner.class) +@Config(sdk = 35) +public class NoteVersionTextTest { + + @Test + public void underAMinute_isJustNow() { + long now = 1_000_000_000L; + assertThat(NoteVersionText.isJustNow(now, now - 59_000)).isTrue(); + assertThat(NoteVersionText.isJustNow(now, now)).isTrue(); + assertThat(NoteVersionText.isJustNow(now, now - 60_000)).isFalse(); + } + + @Test + public void aVersionKeptSecondsAgo_doesNotSayZeroMinutes() { + Context context = RuntimeEnvironment.getApplication(); + String when = NoteVersionText.when(context, System.currentTimeMillis() - 5_000).toString(); + assertThat(when).startsWith("Just now"); + assertThat(when).doesNotContain("0 minutes"); + } + + @Test + public void aChangedDigit_marksTheWholeNumber() { + // "1200" -> "1500": the texts differ only in the second character. + assertThat(NoteVersionText.wholeWords("Total 1200 UAH", 7, 8)) + .asList() + .containsExactly(6, 10) + .inOrder(); + } + + @Test + public void aChangeInsideAWord_marksTheWord() { + assertThat(NoteVersionText.wholeWords("buy milk today", 6, 7)) + .asList() + .containsExactly(4, 8) + .inOrder(); + } + + @Test + public void lettersAddedOnTheOtherSide_markTheWordTheyWentInto() { + // An empty range inside "milk". + assertThat(NoteVersionText.wholeWords("buy milk", 6, 6)) + .asList() + .containsExactly(4, 8) + .inOrder(); + } + + @Test + public void aChangeOnWordBoundaries_staysAsItIs() { + assertThat(NoteVersionText.wholeWords("buy milk now", 3, 8)) + .asList() + .containsExactly(3, 8) + .inOrder(); + assertThat(NoteVersionText.wholeWords("buy milk", 3, 3)) + .asList() + .containsExactly(3, 3) + .inOrder(); + } + + @Test + public void aLongNoteWithThreeChangedLines_marksOnlyThoseLines() { + StringBuilder before = new StringBuilder("Title"); + StringBuilder after = new StringBuilder("Title"); + for (int i = 1; i <= 60; i++) { + before.append("\nLine ").append(i).append(" of the note text"); + String changed = i == 5 || i == 30 || i == 55 ? "edited " : ""; + after.append("\nLine ") + .append(i) + .append(" of the ") + .append(changed) + .append("note text"); + } + String a = before.toString(); + String b = after.toString(); + + java.util.List inAfter = NoteVersionText.changedRanges(b, a); + assertThat(inAfter).hasSize(3); + int marked = 0; + for (int[] range : inAfter) { + assertThat(b.substring(range[0], range[1])).isEqualTo("edited "); + marked += range[1] - range[0]; + } + assertThat(marked).isLessThan(b.length() / 20); + // Words were only added: nothing in the older text is marked. + assertThat(NoteVersionText.changedRanges(a, b)).isEmpty(); + } + + @Test + public void aChangedWordInALongNote_marksThatWordOnBothSides() { + StringBuilder before = new StringBuilder(); + StringBuilder after = new StringBuilder(); + for (int i = 1; i <= 60; i++) { + before.append("Row ").append(i).append(i == 40 ? " total 1200" : " text").append('\n'); + after.append("Row ").append(i).append(i == 40 ? " total 1500" : " text").append('\n'); + } + String a = before.toString(); + String b = after.toString(); + + java.util.List inBefore = NoteVersionText.changedRanges(a, b); + assertThat(inBefore).hasSize(1); + assertThat(a.substring(inBefore.get(0)[0], inBefore.get(0)[1])).isEqualTo("1200"); + java.util.List inAfter = NoteVersionText.changedRanges(b, a); + assertThat(b.substring(inAfter.get(0)[0], inAfter.get(0)[1])).isEqualTo("1500"); + } +} diff --git a/app/src/test/java/com/pasich/mynotes/ui/main/DragHandleTest.java b/app/src/test/java/com/pasich/mynotes/ui/main/DragHandleTest.java new file mode 100644 index 00000000..c47bed71 --- /dev/null +++ b/app/src/test/java/com/pasich/mynotes/ui/main/DragHandleTest.java @@ -0,0 +1,80 @@ +package com.pasich.mynotes.ui.main; + +import static com.google.common.truth.Truth.assertThat; + +import android.view.ContextThemeWrapper; +import android.view.View; +import android.widget.FrameLayout; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; +import com.pasich.mynotes.R; +import com.pasich.mynotes.data.model.Note; +import com.pasich.mynotes.utils.adapters.notes.NoteAdapter; +import java.util.List; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.RuntimeEnvironment; +import org.robolectric.annotation.Config; +import org.robolectric.shadows.ShadowLooper; + +/** The drag handle shows on cards that can be dragged, in the custom order only. */ +@RunWith(RobolectricTestRunner.class) +@Config(sdk = 35) +public class DragHandleTest { + + private NoteAdapter.NoteHolder bound(NoteAdapter adapter, Note note) { + FrameLayout parent = + new FrameLayout( + new ContextThemeWrapper( + RuntimeEnvironment.getApplication(), R.style.DefaultTheme)); + RecyclerView list = new RecyclerView(parent.getContext()); + list.setLayoutManager(new LinearLayoutManager(parent.getContext())); + parent.addView(list); + adapter.setOnItemClickListener(null); + adapter.submitList(List.of(note)); + ShadowLooper.idleMainLooper(); + NoteAdapter.NoteHolder holder = adapter.onCreateViewHolder(list, 0); + adapter.onBindViewHolder(holder, 0); + return holder; + } + + private static Note note(int id, boolean pinned) { + Note note = new Note().create("Title", "Text", 1L, ""); + note.setId(id); + note.setPinned(pinned); + return note; + } + + @Test + public void outsideTheCustomOrder_noHandle() { + NoteAdapter adapter = new NoteAdapter(); + assertThat( + bound(adapter, note(1, false)) + .itemView + .findViewById(R.id.dragHandle) + .getVisibility()) + .isEqualTo(View.GONE); + } + + @Test + public void inTheCustomOrder_unpinnedCardsHaveAHandle_pinnedOnesNot() { + NoteAdapter adapter = new NoteAdapter(); + adapter.setReorderListener((n, step) -> {}); + assertThat( + bound(adapter, note(1, false)) + .itemView + .findViewById(R.id.dragHandle) + .getVisibility()) + .isEqualTo(View.VISIBLE); + + NoteAdapter other = new NoteAdapter(); + other.setReorderListener((n, step) -> {}); + assertThat( + bound(other, note(2, true)) + .itemView + .findViewById(R.id.dragHandle) + .getVisibility()) + .isEqualTo(View.GONE); + } +} diff --git a/app/src/test/java/com/pasich/mynotes/ui/main/ListJumpToTopTest.java b/app/src/test/java/com/pasich/mynotes/ui/main/ListJumpToTopTest.java new file mode 100644 index 00000000..5d1696ed --- /dev/null +++ b/app/src/test/java/com/pasich/mynotes/ui/main/ListJumpToTopTest.java @@ -0,0 +1,222 @@ +package com.pasich.mynotes.ui.main; + +import static com.google.common.truth.Truth.assertThat; +import static org.robolectric.Shadows.shadowOf; + +import android.app.Activity; +import android.os.Looper; +import android.view.View; +import android.view.ViewGroup; +import android.widget.FrameLayout; +import android.widget.ImageView; +import android.widget.TextView; +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.RecyclerView; +import androidx.recyclerview.widget.StaggeredGridLayoutManager; +import com.pasich.mynotes.ui.controllers.mainActivity.MainRenderListsController; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.Robolectric; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; + +/** + * "Show the new top note" after a list change, on a real {@link StaggeredGridLayoutManager} that + * lays out real views. A scroll requested in the same layout pass as the change it reacts to is + * resolved by the layout manager against the children of the old list: a removed first card made it + * lay out nothing at all, a moved card made it land at a wrong offset. + */ +@RunWith(RobolectricTestRunner.class) +@Config(sdk = 35) +public class ListJumpToTopTest { + + private static final int ITEM_HEIGHT = 120; + + private RecyclerView list; + private Items adapter; + private MainRenderListsController controller; + + private void setUp(int spans) { + Activity activity = Robolectric.buildActivity(Activity.class).setup().get(); + FrameLayout root = new FrameLayout(activity); + list = new RecyclerView(activity); + list.setLayoutManager( + new StaggeredGridLayoutManager(spans, StaggeredGridLayoutManager.VERTICAL)); + adapter = new Items(30); + list.setAdapter(adapter); + root.addView(list, new FrameLayout.LayoutParams(400, 600)); + View empty = new FrameLayout(activity); + View tags = new FrameLayout(activity); + root.addView(empty); + root.addView(tags); + activity.setContentView(root); + controller = + new MainRenderListsController( + list, + empty, + new TextView(activity), + new ImageView(activity), + tags, + () -> false); + settle(); + } + + private static void settle() { + shadowOf(Looper.getMainLooper()).idleFor(Duration.ofMillis(200)); + } + + private void scrollDownBy(int items) { + list.scrollBy(0, items * ITEM_HEIGHT); + settle(); + } + + private void assertTopNoteFullyVisible(int expectedId) { + RecyclerView.ViewHolder top = list.findViewHolderForAdapterPosition(0); + assertThat(list.getChildCount()).isGreaterThan(0); + assertThat(top).isNotNull(); + assertThat(adapter.ids.get(0)).isEqualTo(expectedId); + assertThat(((TextView) top.itemView).getText().toString()) + .isEqualTo(String.valueOf(expectedId)); + assertThat(top.itemView.getTop()).isEqualTo(list.getPaddingTop()); + } + + @Test + public void removingTheTopNote_list_keepsTheNotesOnScreen() { + setUp(1); + adapter.remove(0); + controller.jumpToTop(); + settle(); + + assertTopNoteFullyVisible(1); + } + + @Test + public void removingTheTopNote_grid_keepsTheNotesOnScreen() { + setUp(2); + adapter.remove(0); + controller.prepareSwappedList(adapter.getItemCount(), true); + settle(); + + assertTopNoteFullyVisible(1); + } + + @Test + public void aVisibleNoteMovedToTheTop_list_isShownAtTheTop() { + setUp(1); + scrollDownBy(2); + adapter.move(4, 0); + controller.jumpToTop(); + settle(); + + assertTopNoteFullyVisible(4); + } + + @Test + public void aVisibleNoteMovedToTheTop_grid_isShownAtTheTop() { + setUp(2); + scrollDownBy(2); + adapter.move(7, 0); + controller.prepareSwappedList(adapter.getItemCount(), true); + settle(); + + assertTopNoteFullyVisible(7); + } + + @Test + public void aNewNoteAtTheTop_whileScrolledDown_isShownAtTheTop() { + setUp(2); + scrollDownBy(3); + adapter.insertTop(100); + controller.prepareSwappedList(adapter.getItemCount(), true); + settle(); + + assertTopNoteFullyVisible(100); + } + + @Test + public void jumpWithNothingPending_isAppliedRightAway() { + setUp(2); + scrollDownBy(3); + controller.jumpToTop(); + settle(); + + assertTopNoteFullyVisible(0); + } + + @Test + public void isAtTop_followsTheScrollPosition() { + setUp(1); + assertThat(controller.isAtTop()).isTrue(); + + scrollDownBy(1); + assertThat(controller.isAtTop()).isFalse(); + } + + @Test + public void aJumpWaitingForLayout_countsAsAtTop() { + setUp(2); + scrollDownBy(3); + adapter.insertTop(100); + controller.jumpToTop(); + + assertThat(controller.isAtTop()).isTrue(); + settle(); + assertTopNoteFullyVisible(100); + } + + @Test + public void aNoteEditedBelowAPinnedNote_jumpKeepsTheFirstNoteAtTheTop() { + setUp(1); + adapter.move(5, 1); + controller.jumpToTop(); + settle(); + + assertTopNoteFullyVisible(0); + } + + private static final class Items extends RecyclerView.Adapter { + final List ids = new ArrayList<>(); + + Items(int count) { + for (int i = 0; i < count; i++) ids.add(i); + } + + void remove(int position) { + ids.remove(position); + notifyItemRemoved(position); + } + + void move(int from, int to) { + ids.add(to, ids.remove(from)); + notifyItemMoved(from, to); + } + + void insertTop(int id) { + ids.add(0, id); + notifyItemInserted(0); + } + + @NonNull + @Override + public RecyclerView.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int type) { + TextView view = new TextView(parent.getContext()); + view.setLayoutParams( + new RecyclerView.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, ITEM_HEIGHT)); + return new RecyclerView.ViewHolder(view) {}; + } + + @Override + public void onBindViewHolder(@NonNull RecyclerView.ViewHolder holder, int position) { + ((TextView) holder.itemView).setText(String.valueOf(ids.get(position))); + } + + @Override + public int getItemCount() { + return ids.size(); + } + } +} diff --git a/app/src/test/java/com/pasich/mynotes/ui/main/MainRenderListsControllerTest.java b/app/src/test/java/com/pasich/mynotes/ui/main/MainRenderListsControllerTest.java new file mode 100644 index 00000000..8088a793 --- /dev/null +++ b/app/src/test/java/com/pasich/mynotes/ui/main/MainRenderListsControllerTest.java @@ -0,0 +1,192 @@ +package com.pasich.mynotes.ui.main; + +import static com.google.common.truth.Truth.assertThat; +import static org.robolectric.Shadows.shadowOf; + +import android.app.Activity; +import android.os.Looper; +import android.view.View; +import android.widget.FrameLayout; +import android.widget.ImageView; +import android.widget.TextView; +import androidx.recyclerview.widget.RecyclerView; +import androidx.recyclerview.widget.StaggeredGridLayoutManager; +import com.pasich.mynotes.data.model.Tag; +import com.pasich.mynotes.ui.controllers.mainActivity.MainRenderListsController; +import java.time.Duration; +import java.util.List; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mockito; +import org.robolectric.Robolectric; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; + +@RunWith(RobolectricTestRunner.class) +@Config(sdk = 35) +public class MainRenderListsControllerTest { + + private RecyclerView list; + private View empty; + private View tags; + private final AtomicBoolean animationsOn = new AtomicBoolean(true); + private MainRenderListsController controller; + + @Before + public void setUp() { + Activity activity = Robolectric.buildActivity(Activity.class).setup().get(); + FrameLayout root = new FrameLayout(activity); + list = new RecyclerView(activity); + list.setLayoutManager( + new StaggeredGridLayoutManager(2, StaggeredGridLayoutManager.VERTICAL)); + empty = new FrameLayout(activity); + empty.setVisibility(View.GONE); + tags = new FrameLayout(activity); + tags.setVisibility(View.GONE); + root.addView(list); + root.addView(empty); + root.addView(tags); + activity.setContentView(root); + + controller = + new MainRenderListsController( + list, + empty, + new TextView(activity), + new ImageView(activity), + tags, + animationsOn::get); + } + + private static void runAnimations() { + shadowOf(Looper.getMainLooper()).idleFor(Duration.ofSeconds(2)); + } + + @Test + public void emptyThenNotesWithinTheHideAnimation_listEndsVisible() { + controller.showStateNoteList(null, 0, true); + // Sync fills the list again before the 160 ms hide finishes. + shadowOf(Looper.getMainLooper()).idleFor(Duration.ofMillis(40)); + controller.showStateNoteList(null, 5, true); + runAnimations(); + + assertThat(list.getVisibility()).isEqualTo(View.VISIBLE); + assertThat(list.getAlpha()).isEqualTo(1f); + assertThat(empty.getVisibility()).isEqualTo(View.GONE); + } + + @Test + public void notesThenEmpty_emptyStateEndsVisible() { + controller.showStateNoteList(null, 5, true); + controller.showStateNoteList(null, 0, true); + runAnimations(); + + assertThat(list.getVisibility()).isEqualTo(View.INVISIBLE); + assertThat(empty.getVisibility()).isEqualTo(View.VISIBLE); + assertThat(empty.getAlpha()).isEqualTo(1f); + } + + @Test + public void repeatedRequests_areIdempotent() { + controller.showStateNoteList(null, 0, true); + controller.showStateNoteList(null, 0, true); + runAnimations(); + controller.showStateNoteList(null, 3, true); + controller.showStateNoteList(null, 3, true); + runAnimations(); + + assertThat(list.getVisibility()).isEqualTo(View.VISIBLE); + assertThat(list.getAlpha()).isEqualTo(1f); + assertThat(list.getScaleY()).isEqualTo(1f); + assertThat(empty.getVisibility()).isEqualTo(View.GONE); + } + + @Test + public void animationsOff_appliesImmediately() { + animationsOn.set(false); + controller.showStateNoteList(null, 0, true); + + assertThat(list.getVisibility()).isEqualTo(View.INVISIBLE); + assertThat(empty.getVisibility()).isEqualTo(View.VISIBLE); + + controller.showStateNoteList(null, 2, true); + assertThat(list.getVisibility()).isEqualTo(View.VISIBLE); + assertThat(list.getAlpha()).isEqualTo(1f); + assertThat(empty.getVisibility()).isEqualTo(View.GONE); + } + + @Test + public void swap_submitsWhileHiddenAndRevealsAfterLayout() { + AtomicInteger submits = new AtomicInteger(); + controller.swapListContent(true, submits::incrementAndGet); + assertThat(controller.isSwapping()).isTrue(); + // A newer swap during the fade replaces the older one: only the latest list is submitted. + controller.swapListContent(true, submits::incrementAndGet); + runAnimations(); + assertThat(submits.get()).isEqualTo(1); + assertThat(list.getAlpha()).isEqualTo(0f); + + controller.showStateNoteList(null, 4, true); + list.requestLayout(); + runAnimations(); + + assertThat(controller.isSwapping()).isFalse(); + assertThat(list.getVisibility()).isEqualTo(View.VISIBLE); + assertThat(list.getAlpha()).isEqualTo(1f); + } + + @Test + public void swapWithAnimationsOff_submitsAtOnce() { + animationsOn.set(false); + AtomicInteger submits = new AtomicInteger(); + controller.swapListContent(true, submits::incrementAndGet); + assertThat(submits.get()).isEqualTo(1); + assertThat(controller.isSwapping()).isFalse(); + } + + @Test + public void tagsRowCollapseThenExpand_endsVisible() { + controller.renderListTags(List.of(new Tag().create("work")), true); + runAnimations(); + controller.renderListTags(List.of(), true); + shadowOf(Looper.getMainLooper()).idleFor(Duration.ofMillis(30)); + controller.renderListTags(List.of(new Tag().create("work")), true); + runAnimations(); + + assertThat(tags.getVisibility()).isEqualTo(View.VISIBLE); + assertThat(tags.getAlpha()).isEqualTo(1f); + } + + @Test + public void swappingInAnEmptyList_leavesTheGridAlone() { + // Resetting the spans of a StaggeredGridLayoutManager or scrolling it while it has no + // items left it anchored wrong on a device: the next list was laid out below the screen. + StaggeredGridLayoutManager grid = + Mockito.spy(new StaggeredGridLayoutManager(1, StaggeredGridLayoutManager.VERTICAL)); + list.setLayoutManager(grid); + Mockito.clearInvocations(grid); + + controller.prepareSwappedList(0, true); + + Mockito.verify(grid, Mockito.never()).invalidateSpanAssignments(); + Mockito.verify(grid, Mockito.never()) + .scrollToPositionWithOffset(Mockito.anyInt(), Mockito.anyInt()); + } + + @Test + public void swappingInNotes_rebuildsTheColumns() { + StaggeredGridLayoutManager grid = + Mockito.spy(new StaggeredGridLayoutManager(2, StaggeredGridLayoutManager.VERTICAL)); + list.setLayoutManager(grid); + Mockito.clearInvocations(grid); + + controller.prepareSwappedList(14, false); + Mockito.verify(grid).invalidateSpanAssignments(); + Mockito.verify(grid, Mockito.never()) + .scrollToPositionWithOffset(Mockito.anyInt(), Mockito.anyInt()); + // Where the jump to the top lands is covered on real layouts by ListJumpToTopTest. + } +} diff --git a/app/src/test/java/com/pasich/mynotes/ui/main/ViewOptionsDialogTest.java b/app/src/test/java/com/pasich/mynotes/ui/main/ViewOptionsDialogTest.java new file mode 100644 index 00000000..2049949e --- /dev/null +++ b/app/src/test/java/com/pasich/mynotes/ui/main/ViewOptionsDialogTest.java @@ -0,0 +1,128 @@ +package com.pasich.mynotes.ui.main; + +import static com.google.common.truth.Truth.assertThat; +import static org.robolectric.Shadows.shadowOf; + +import android.os.Bundle; +import android.os.Looper; +import android.view.View; +import android.widget.RadioButton; +import androidx.annotation.Nullable; +import androidx.appcompat.app.AppCompatActivity; +import com.google.android.material.button.MaterialButton; +import com.pasich.mynotes.R; +import com.pasich.mynotes.ui.view.dialogs.main.ViewOptionsDialog; +import com.pasich.mynotes.utils.constants.settings.SortParam; +import com.pasich.mynotes.utils.tool.FormatListTool; +import java.util.ArrayList; +import java.util.List; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.Robolectric; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; + +@RunWith(RobolectricTestRunner.class) +@Config(sdk = 35) +public class ViewOptionsDialogTest { + + /** Stands in for MainActivity: records what the sheet asks it to apply. */ + public static class Host extends AppCompatActivity implements ViewOptionsDialog.Listener { + final List sorts = new ArrayList<>(); + final List formats = new ArrayList<>(); + + @Override + protected void onCreate(@Nullable Bundle savedInstanceState) { + setTheme(com.google.android.material.R.style.Theme_Material3_Light_NoActionBar); + super.onCreate(savedInstanceState); + } + + @Override + public void onViewSortSelected(String sortParam) { + sorts.add(sortParam); + } + + @Override + public void onViewLayoutSelected(int format) { + formats.add(format); + } + } + + private Host host; + + private View open(String sort, int format) { + host = Robolectric.buildActivity(Host.class).setup().get(); + ViewOptionsDialog dialog = ViewOptionsDialog.newInstance(sort, format); + dialog.show(host.getSupportFragmentManager(), ViewOptionsDialog.TAG); + host.getSupportFragmentManager().executePendingTransactions(); + shadowOf(Looper.getMainLooper()).idle(); + return dialog.requireView(); + } + + @Test + public void showsTheCurrentSortAndLayout() { + View root = open(SortParam.DataReserve, FormatListTool.FORMAT_GRID); + + assertThat(((RadioButton) root.findViewById(R.id.sortOldest)).isChecked()).isTrue(); + assertThat(((RadioButton) root.findViewById(R.id.sortNewest)).isChecked()).isFalse(); + assertThat(((MaterialButton) root.findViewById(R.id.layoutGrid)).isChecked()).isTrue(); + assertThat(((MaterialButton) root.findViewById(R.id.layoutList)).isChecked()).isFalse(); + assertThat(host.sorts).isEmpty(); + assertThat(host.formats).isEmpty(); + } + + @Test + public void choosingAnotherSort_appliesIt() { + View root = open(SortParam.DataSort, FormatListTool.FORMAT_LIST); + + root.findViewById(R.id.sortOldest).performClick(); + shadowOf(Looper.getMainLooper()).idle(); + + assertThat(host.sorts).containsExactly(SortParam.DataReserve); + assertThat(host.formats).isEmpty(); + } + + @Test + public void choosingAnotherLayout_appliesIt() { + View root = open(SortParam.DataSort, FormatListTool.FORMAT_LIST); + + root.findViewById(R.id.layoutGrid).performClick(); + shadowOf(Looper.getMainLooper()).idle(); + + assertThat(host.formats).containsExactly(FormatListTool.FORMAT_GRID); + assertThat(host.sorts).isEmpty(); + } + + @Test + public void tappingTheCurrentChoices_appliesNothing() { + View root = open(SortParam.DataSort, FormatListTool.FORMAT_GRID); + + root.findViewById(R.id.sortNewest).performClick(); + root.findViewById(R.id.layoutGrid).performClick(); + shadowOf(Looper.getMainLooper()).idle(); + + assertThat(host.sorts).isEmpty(); + assertThat(host.formats).isEmpty(); + assertThat(((MaterialButton) root.findViewById(R.id.layoutGrid)).isChecked()).isTrue(); + } + + @Test + public void choosingTheCustomOrder_appliesItAndShowsHowToDrag() { + View root = open(SortParam.DataSort, FormatListTool.FORMAT_LIST); + assertThat(root.findViewById(R.id.sortCustomHint).getVisibility()).isEqualTo(View.GONE); + + root.findViewById(R.id.sortCustom).performClick(); + shadowOf(Looper.getMainLooper()).idle(); + + assertThat(host.sorts).containsExactly(SortParam.Custom); + assertThat(root.findViewById(R.id.sortCustomHint).getVisibility()).isEqualTo(View.VISIBLE); + } + + @Test + public void reopenedInTheCustomOrder_showsItChecked() { + View root = open(SortParam.Custom, FormatListTool.FORMAT_GRID); + + assertThat(((RadioButton) root.findViewById(R.id.sortCustom)).isChecked()).isTrue(); + assertThat(root.findViewById(R.id.sortCustomHint).getVisibility()).isEqualTo(View.VISIBLE); + } +} diff --git a/app/src/test/java/com/pasich/mynotes/ui/sync/SyncConflictPresentationTest.java b/app/src/test/java/com/pasich/mynotes/ui/sync/SyncConflictPresentationTest.java index 2c5ac0ed..4554cf36 100644 --- a/app/src/test/java/com/pasich/mynotes/ui/sync/SyncConflictPresentationTest.java +++ b/app/src/test/java/com/pasich/mynotes/ui/sync/SyncConflictPresentationTest.java @@ -271,4 +271,27 @@ private static SyncConflictEntity conflict( 1L, 0L); } + + @Test + public void compare_windowsEachTextAroundTheirDifference() { + String head = "x".repeat(300); + SyncConflictPresentation.Comparison comparison = + SyncConflictPresentation.compare(head + "old tail", head + "new tail", 40); + + // Both windows keep the difference on screen, however far into the text it is. + assertThat(comparison.first.text.substring(comparison.first.start, comparison.first.end)) + .isEqualTo("old"); + assertThat(comparison.second.text.substring(comparison.second.start, comparison.second.end)) + .isEqualTo("new"); + assertThat(comparison.first.text).startsWith("…"); + } + + @Test + public void compare_equalTextsHaveNoHighlight() { + SyncConflictPresentation.Comparison comparison = + SyncConflictPresentation.compare("same", "same", 40); + + assertThat(comparison.first.end).isEqualTo(comparison.first.start); + assertThat(comparison.second.text).isEqualTo("same"); + } } diff --git a/app/src/test/java/com/pasich/mynotes/ui/view/activity/noteEditor/NoteToolbarMenuTest.java b/app/src/test/java/com/pasich/mynotes/ui/view/activity/noteEditor/NoteToolbarMenuTest.java new file mode 100644 index 00000000..6a686ed9 --- /dev/null +++ b/app/src/test/java/com/pasich/mynotes/ui/view/activity/noteEditor/NoteToolbarMenuTest.java @@ -0,0 +1,66 @@ +package com.pasich.mynotes.ui.view.activity.noteEditor; + +import static com.google.common.truth.Truth.assertThat; + +import android.content.Context; +import android.view.Menu; +import android.view.View; +import android.widget.PopupMenu; +import androidx.appcompat.view.ContextThemeWrapper; +import com.pasich.mynotes.R; +import java.util.ArrayList; +import java.util.List; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.RuntimeEnvironment; +import org.robolectric.annotation.Config; + +/** Undo and Redo moved to the bar above the keyboard; the note toolbars no longer carry them. */ +@RunWith(RobolectricTestRunner.class) +@Config(sdk = 35) +public class NoteToolbarMenuTest { + + private final Context context = + new ContextThemeWrapper(RuntimeEnvironment.getApplication(), R.style.DefaultTheme); + + private Menu inflate(int menuRes) { + PopupMenu popup = new PopupMenu(context, new View(context)); + popup.getMenuInflater().inflate(menuRes, popup.getMenu()); + return popup.getMenu(); + } + + private static List ids(Menu menu) { + List ids = new ArrayList<>(); + for (int i = 0; i < menu.size(); i++) ids.add(menu.getItem(i).getItemId()); + return ids; + } + + private void assertNoHistoryItems(Menu menu) { + String undo = context.getString(R.string.editor_undo); + String redo = context.getString(R.string.editor_redo); + for (int i = 0; i < menu.size(); i++) { + CharSequence title = menu.getItem(i).getTitle(); + assertThat(String.valueOf(title)).isNotEqualTo(undo); + assertThat(String.valueOf(title)).isNotEqualTo(redo); + } + } + + @Test + public void simpleEditorToolbar_isSaveStatusAndMore() { + Menu menu = inflate(R.menu.menu_activity_toolbar_note); + + assertThat(ids(menu)).containsExactly(R.id.saveStatusBut, R.id.moreBut).inOrder(); + assertNoHistoryItems(menu); + } + + @Test + public void extendedEditorToolbar_isSaveStatusReadAndMore() { + Menu menu = inflate(R.menu.menu_activity_toolbar_note_extendes); + + assertThat(ids(menu)) + .containsExactly(R.id.saveStatusBut, R.id.actionRead, R.id.moreBut) + .inOrder(); + assertNoHistoryItems(menu); + } +} diff --git a/app/src/test/java/com/pasich/mynotes/ui/view/dialogs/EditHistoryRowsTest.java b/app/src/test/java/com/pasich/mynotes/ui/view/dialogs/EditHistoryRowsTest.java new file mode 100644 index 00000000..6599bed7 --- /dev/null +++ b/app/src/test/java/com/pasich/mynotes/ui/view/dialogs/EditHistoryRowsTest.java @@ -0,0 +1,159 @@ +package com.pasich.mynotes.ui.view.dialogs; + +import static com.google.common.truth.Truth.assertThat; + +import android.content.Context; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.TextView; +import androidx.annotation.Nullable; +import androidx.appcompat.view.ContextThemeWrapper; +import com.pasich.mynotes.R; +import com.pasich.mynotes.base.view.MoreNoteNoteActivityView; +import com.pasich.mynotes.databinding.DialogMoreNoteBinding; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.RuntimeEnvironment; +import org.robolectric.annotation.Config; + +/** Undo and Redo in the editor's More sheet. */ +@RunWith(RobolectricTestRunner.class) +@Config(sdk = 35) +public class EditHistoryRowsTest { + + /** Stands in for a note editor. */ + private static final class Editor implements MoreNoteNoteActivityView { + boolean editing; + boolean canUndo; + boolean canRedo; + + @Override + public boolean isEditingNote() { + return editing; + } + + @Override + public boolean canUndoEdit() { + return canUndo; + } + + @Override + public boolean canRedoEdit() { + return canRedo; + } + + @Override + public void undoLastEdit() {} + + @Override + public void redoLastEdit() {} + + @Override + public void setEditHistoryObserver(@Nullable Runnable observer) {} + + @Override + public void changeTextStyle() {} + + @Override + public void changeTextSizeOnline(int sizeText) {} + + @Override + public void changeTextSizeOffline() {} + + @Override + public void closeActivityNotSaved() {} + + @Override + public void changeTag(String nameTag, boolean change) {} + + @Override + public void openCopyNote(long idNote) {} + + @Override + public void changeEditor(long idNote) {} + + @Override + public void openVersionHistory() {} + } + + private DialogMoreNoteBinding binding; + private final Editor editor = new Editor(); + + @Before + public void setUp() { + Context context = + new ContextThemeWrapper(RuntimeEnvironment.getApplication(), R.style.DefaultTheme); + binding = DialogMoreNoteBinding.inflate(LayoutInflater.from(context)); + } + + private void apply(@Nullable MoreNoteNoteActivityView host) { + EditHistoryRows.apply(binding.editHistoryGroup, binding.moreUndo, binding.moreRedo, host); + } + + private static float contentAlpha(View row) { + return ((ViewGroup) row).getChildAt(0).getAlpha(); + } + + @Test + public void hiddenUntilInflatedForAnEditor() { + assertThat(binding.editHistoryGroup.getVisibility()).isEqualTo(View.GONE); + + apply(null); + + assertThat(binding.editHistoryGroup.getVisibility()).isEqualTo(View.GONE); + } + + @Test + public void hiddenWhileTheNoteIsRead() { + editor.canUndo = true; + + apply(editor); + + assertThat(binding.editHistoryGroup.getVisibility()).isEqualTo(View.GONE); + } + + @Test + public void shownWhileEditing_enabledByTheHistory() { + editor.editing = true; + editor.canUndo = true; + + apply(editor); + + assertThat(binding.editHistoryGroup.getVisibility()).isEqualTo(View.VISIBLE); + assertThat(binding.moreUndo.isEnabled()).isTrue(); + assertThat(contentAlpha(binding.moreUndo)).isEqualTo(1f); + assertThat(binding.moreRedo.isEnabled()).isFalse(); + assertThat(contentAlpha(binding.moreRedo)).isEqualTo(EditHistoryRows.DISABLED_ALPHA); + } + + @Test + public void followsTheHistoryWhenAppliedAgain() { + editor.editing = true; + editor.canUndo = true; + apply(editor); + + editor.canUndo = false; + editor.canRedo = true; + apply(editor); + + assertThat(binding.moreUndo.isEnabled()).isFalse(); + assertThat(contentAlpha(binding.moreUndo)).isEqualTo(EditHistoryRows.DISABLED_ALPHA); + assertThat(binding.moreRedo.isEnabled()).isTrue(); + assertThat(contentAlpha(binding.moreRedo)).isEqualTo(1f); + } + + @Test + public void rowsAreLabelledUndoAndRedo() { + Context context = binding.getRoot().getContext(); + TextView undoLabel = (TextView) ((ViewGroup) binding.moreUndo).getChildAt(1); + TextView redoLabel = (TextView) ((ViewGroup) binding.moreRedo).getChildAt(1); + + assertThat(undoLabel.getText().toString()) + .isEqualTo(context.getString(R.string.editor_undo)); + assertThat(redoLabel.getText().toString()) + .isEqualTo(context.getString(R.string.editor_redo)); + } +} diff --git a/app/src/test/java/com/pasich/mynotes/ui/view/dialogs/LabelsFitTest.java b/app/src/test/java/com/pasich/mynotes/ui/view/dialogs/LabelsFitTest.java new file mode 100644 index 00000000..84215323 --- /dev/null +++ b/app/src/test/java/com/pasich/mynotes/ui/view/dialogs/LabelsFitTest.java @@ -0,0 +1,79 @@ +package com.pasich.mynotes.ui.view.dialogs; + +import static com.google.common.truth.Truth.assertThat; + +import android.content.Context; +import android.view.ContextThemeWrapper; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.TextView; +import com.pasich.mynotes.R; +import com.pasich.mynotes.ui.view.widgets.QuickActionsRow; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.RuntimeEnvironment; +import org.robolectric.annotation.Config; + +/** Labels that were cut off ("Edit a co…", "Tonig…") may wrap to a second line instead. */ +@RunWith(RobolectricTestRunner.class) +@Config(sdk = 35) +public class LabelsFitTest { + + private final Context context = + new ContextThemeWrapper(RuntimeEnvironment.getApplication(), R.style.DefaultTheme); + + @Test + public void reminderPresets_mayWrap() { + View sheet = LayoutInflater.from(context).inflate(R.layout.dialog_reminder_picker, null); + assertThat(((TextView) sheet.findViewById(R.id.presetToday)).getMaxLines()).isAtLeast(2); + assertThat(((TextView) sheet.findViewById(R.id.presetTomorrow)).getMaxLines()).isAtLeast(2); + } + + @Test + public void moreQuickActions_mayWrap() { + View sheet = LayoutInflater.from(context).inflate(R.layout.dialog_more_note, null); + ViewGroup copy = sheet.findViewById(R.id.quickCopy); + TextView label = null; + for (int i = 0; i < copy.getChildCount(); i++) { + if (copy.getChildAt(i) instanceof TextView text) label = text; + } + assertThat(label).isNotNull(); + assertThat(label.getMaxLines()).isAtLeast(2); + } + + /** 10 px a character: "Редагувати" is 100 px wide. */ + private static double tenPerChar(String text) { + return text.length() * 10.0; + } + + @Test + public void aLabelWrapsOnlyBetweenWords_inTwoLines() { + assertThat(QuickActionsRow.fits("Редагувати копію нотатки", 130, LabelsFitTest::tenPerChar)) + .isTrue(); + // Three lines would be needed: the label would be cut off. + assertThat(QuickActionsRow.fits("Зробити переклад нотатки", 100, LabelsFitTest::tenPerChar)) + .isFalse(); + // "Редагувати" is wider than the cell: it would be broken inside the word. + assertThat(QuickActionsRow.fits("Редагувати копію", 90, LabelsFitTest::tenPerChar)) + .isFalse(); + assertThat(QuickActionsRow.fits("Поділитися", 100, LabelsFitTest::tenPerChar)).isTrue(); + } + + @Test + public void moreQuickActions_neverBreakInsideAWord() { + View sheet = LayoutInflater.from(context).inflate(R.layout.dialog_more_note, null); + assertThat((View) sheet.findViewById(R.id.quickActionsRow)) + .isInstanceOf(QuickActionsRow.class); + ViewGroup copy = sheet.findViewById(R.id.quickCopy); + for (int i = 0; i < copy.getChildCount(); i++) { + if (copy.getChildAt(i) instanceof TextView label) { + assertThat(label.getBreakStrategy()) + .isEqualTo(android.text.Layout.BREAK_STRATEGY_SIMPLE); + assertThat(label.getHyphenationFrequency()) + .isEqualTo(android.text.Layout.HYPHENATION_FREQUENCY_NONE); + } + } + } +} diff --git a/app/src/test/java/com/pasich/mynotes/ui/view/dialogs/NoteVersionSheetTest.java b/app/src/test/java/com/pasich/mynotes/ui/view/dialogs/NoteVersionSheetTest.java new file mode 100644 index 00000000..566765c8 --- /dev/null +++ b/app/src/test/java/com/pasich/mynotes/ui/view/dialogs/NoteVersionSheetTest.java @@ -0,0 +1,45 @@ +package com.pasich.mynotes.ui.view.dialogs; + +import static com.google.common.truth.Truth.assertThat; + +import com.pasich.mynotes.data.database.entities.NoteVersionEntity; +import com.pasich.mynotes.data.model.Note; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; + +@RunWith(RobolectricTestRunner.class) +@Config(sdk = 35) +public class NoteVersionSheetTest { + + @Test + public void aSheetRecreatedBeforeItsHostHasReloaded_waitsInsteadOfClosing() { + assertThat(NoteVersionSheet.decide(false, null, null)) + .isEqualTo(NoteVersionSheet.Show.WAIT); + assertThat(NoteVersionSheet.decide(false, new Note(), null)) + .isEqualTo(NoteVersionSheet.Show.WAIT); + } + + @Test + public void aVersionThatIsGone_closesTheSheet() { + assertThat(NoteVersionSheet.decide(true, new Note(), null)) + .isEqualTo(NoteVersionSheet.Show.CLOSE); + } + + @Test + public void withTheNoteAndTheVersion_theSheetShowsIt() { + assertThat( + NoteVersionSheet.decide( + true, + new Note(), + new NoteVersionEntity(1, null, "", "", null, null, 0L, "edit"))) + .isEqualTo(NoteVersionSheet.Show.BIND); + } + + @Test + public void theRestoreQuestionKeepsItsVersionInItsArguments() { + // A dialog fragment is recreated from its arguments after a rotation. + assertThat(RestoreVersionDialog.newInstance(77).versionId()).isEqualTo(77); + } +} diff --git a/app/src/test/java/com/pasich/mynotes/ui/view/widgets/EditorKeyboardBarTest.java b/app/src/test/java/com/pasich/mynotes/ui/view/widgets/EditorKeyboardBarTest.java new file mode 100644 index 00000000..789056c6 --- /dev/null +++ b/app/src/test/java/com/pasich/mynotes/ui/view/widgets/EditorKeyboardBarTest.java @@ -0,0 +1,181 @@ +package com.pasich.mynotes.ui.view.widgets; + +import static com.google.common.truth.Truth.assertThat; + +import android.content.Context; +import android.view.View; +import android.view.ViewGroup; +import android.widget.FrameLayout; +import androidx.appcompat.view.ContextThemeWrapper; +import androidx.core.graphics.Insets; +import androidx.core.view.WindowInsetsCompat; +import com.pasich.mynotes.R; +import java.util.ArrayList; +import java.util.List; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.RuntimeEnvironment; +import org.robolectric.annotation.Config; + +@RunWith(RobolectricTestRunner.class) +@Config(sdk = 35) +public class EditorKeyboardBarTest { + + private static final int NAV_BAR = 48; + private static final int KEYBOARD = 900; + + private EditorKeyboardBar bar; + private final List calls = new ArrayList<>(); + + @Before + public void setUp() { + Context context = + new ContextThemeWrapper(RuntimeEnvironment.getApplication(), R.style.DefaultTheme); + FrameLayout parent = new FrameLayout(context); + bar = new EditorKeyboardBar(context); + parent.addView( + bar, + new FrameLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)); + bar.setActions( + new EditorKeyboardBar.Actions() { + @Override + public void onUndo() { + calls.add("undo"); + } + + @Override + public void onRedo() { + calls.add("redo"); + } + + @Override + public void onHideKeyboard() { + calls.add("hide"); + } + }); + } + + private static WindowInsetsCompat insets(boolean keyboard) { + return new WindowInsetsCompat.Builder() + .setInsets(WindowInsetsCompat.Type.navigationBars(), Insets.of(0, 0, 0, NAV_BAR)) + .setInsets( + WindowInsetsCompat.Type.ime(), Insets.of(0, 0, 0, keyboard ? KEYBOARD : 0)) + .setVisible(WindowInsetsCompat.Type.ime(), keyboard) + .build(); + } + + private View undo() { + return bar.findViewById(R.id.keyboardBarUndo); + } + + private View redo() { + return bar.findViewById(R.id.keyboardBarRedo); + } + + @Test + public void hiddenAndReservingNothingByDefault() { + assertThat(bar.getVisibility()).isEqualTo(View.GONE); + assertThat(bar.getReservedHeight()).isEqualTo(0); + } + + @Test + public void showsOnTopOfTheKeyboardWhileEditing() { + bar.setEditing(true); + bar.onWindowInsets(insets(true)); + + assertThat(bar.getVisibility()).isEqualTo(View.VISIBLE); + assertThat(((ViewGroup.MarginLayoutParams) bar.getLayoutParams()).bottomMargin) + .isEqualTo(KEYBOARD); + int expected = + bar.getResources().getDimensionPixelSize(R.dimen.editor_keyboard_bar_row_height) + + bar.getResources() + .getDimensionPixelSize(R.dimen.editor_keyboard_bar_divider); + assertThat(bar.getReservedHeight()).isEqualTo(expected); + } + + @Test + public void hiddenWithoutTheOnScreenKeyboard() { + // A hardware keyboard: editing, no IME. + bar.setEditing(true); + bar.onWindowInsets(insets(false)); + + assertThat(bar.getVisibility()).isEqualTo(View.GONE); + assertThat(bar.getReservedHeight()).isEqualTo(0); + assertThat(((ViewGroup.MarginLayoutParams) bar.getLayoutParams()).bottomMargin) + .isEqualTo(NAV_BAR); + } + + @Test + public void hiddenWhileReading() { + bar.onWindowInsets(insets(true)); + assertThat(bar.getVisibility()).isEqualTo(View.GONE); + + bar.setEditing(true); + assertThat(bar.getVisibility()).isEqualTo(View.VISIBLE); + + bar.setEditing(false); + assertThat(bar.getVisibility()).isEqualTo(View.GONE); + assertThat(bar.getReservedHeight()).isEqualTo(0); + } + + @Test + public void goesAwayWhenTheKeyboardCloses() { + bar.setEditing(true); + bar.onWindowInsets(insets(true)); + + bar.onWindowInsets(insets(false)); + + assertThat(bar.getVisibility()).isEqualTo(View.GONE); + assertThat(bar.getReservedHeight()).isEqualTo(0); + } + + @Test + public void undoAndRedoFollowTheHistory() { + bar.setEditing(true); + assertThat(undo().isEnabled()).isFalse(); + assertThat(redo().isEnabled()).isFalse(); + + bar.setHistoryState(true, false); + assertThat(undo().isEnabled()).isTrue(); + assertThat(redo().isEnabled()).isFalse(); + + bar.setHistoryState(false, true); + assertThat(undo().isEnabled()).isFalse(); + assertThat(redo().isEnabled()).isTrue(); + } + + @Test + public void historyIsOffWhileReading() { + bar.setHistoryState(true, true); + + assertThat(undo().isEnabled()).isFalse(); + assertThat(redo().isEnabled()).isFalse(); + } + + @Test + public void buttonsAskTheEditor() { + bar.setEditing(true); + bar.setHistoryState(true, true); + + undo().performClick(); + redo().performClick(); + bar.findViewById(R.id.keyboardBarHide).performClick(); + + assertThat(calls).containsExactly("undo", "redo", "hide").inOrder(); + } + + @Test + public void buttonsAreLabelledForAccessibility() { + assertThat(undo().getContentDescription().toString()) + .isEqualTo(bar.getContext().getString(R.string.editor_undo)); + assertThat(redo().getContentDescription().toString()) + .isEqualTo(bar.getContext().getString(R.string.editor_redo)); + assertThat(bar.findViewById(R.id.keyboardBarHide).getContentDescription().toString()) + .isEqualTo(bar.getContext().getString(R.string.editor_hide_keyboard)); + assertThat(undo().getTooltipText().toString()) + .isEqualTo(bar.getContext().getString(R.string.editor_undo)); + } +} diff --git a/app/src/test/java/com/pasich/mynotes/utils/BackupParserTest.java b/app/src/test/java/com/pasich/mynotes/utils/BackupParserTest.java index d3afc666..728b3f4e 100644 --- a/app/src/test/java/com/pasich/mynotes/utils/BackupParserTest.java +++ b/app/src/test/java/com/pasich/mynotes/utils/BackupParserTest.java @@ -80,4 +80,23 @@ public void noteSerializedNames_shortFieldNames_mapCorrectly() { assertThat(restored.getValue()).isEqualTo("MyValue"); assertThat(restored.getTag()).isEqualTo("MyTag"); } + + @Test + public void jsonBackup_keepsTheCustomOrderAndReadsOlderBackupsWithout() { + Note note = new Note().create("Title", "Body", 12345L, ""); + note.setCustomPosition(5120L); + JsonBackup backup = new JsonBackup(); + backup.setNotes(Collections.singletonList(note)); + backup.setTags(Collections.emptyList()); + + String json = gson.toJson(backup); + assertThat(json).contains("\"n\":5120"); + JsonBackup restored = gson.fromJson(json, JsonBackup.class); + assertThat(restored.getNotes().get(0).getCustomPosition()).isEqualTo(5120L); + + // A backup from before the custom order: the note comes back unplaced (0), and the + // restore gives it a place on top. + JsonBackup older = gson.fromJson(json.replace(",\"n\":5120", ""), JsonBackup.class); + assertThat(older.getNotes().get(0).getCustomPosition()).isEqualTo(0L); + } } diff --git a/app/src/test/java/com/pasich/mynotes/utils/ReminderRepeatTest.java b/app/src/test/java/com/pasich/mynotes/utils/ReminderRepeatTest.java index 37bce170..01757120 100644 --- a/app/src/test/java/com/pasich/mynotes/utils/ReminderRepeatTest.java +++ b/app/src/test/java/com/pasich/mynotes/utils/ReminderRepeatTest.java @@ -3,6 +3,7 @@ import static org.junit.Assert.assertEquals; import com.pasich.mynotes.data.model.ReminderRepeat; +import com.pasich.mynotes.data.model.RepeatRule; import org.junit.Test; public class ReminderRepeatTest { @@ -47,4 +48,29 @@ public void from_lowercaseDaily_returnsNone() { // Case-sensitive: lowercase is not a valid enum value assertEquals(ReminderRepeat.NONE, ReminderRepeat.from("daily")); } + + @Test + public void from_customRule_isNoneForTheLegacyReader() { + // What a version without custom repeats sees: a one-time reminder, not a crash. + assertEquals(ReminderRepeat.NONE, ReminderRepeat.from("EVERY:3:HOURS@1767225600000")); + } + + @Test + public void toRule_mapsEachLegacyValue() { + assertEquals(RepeatRule.NONE, ReminderRepeat.NONE.toRule()); + assertEquals( + RepeatRule.every(1, RepeatRule.Unit.DAYS, null), ReminderRepeat.DAILY.toRule()); + assertEquals( + RepeatRule.every(1, RepeatRule.Unit.WEEKS, null), ReminderRepeat.WEEKLY.toRule()); + assertEquals( + RepeatRule.every(1, RepeatRule.Unit.MONTHS, null), ReminderRepeat.MONTHLY.toRule()); + } + + @Test + public void legacyValues_areStillWrittenForOlderVersions() { + for (ReminderRepeat repeat : ReminderRepeat.values()) { + assertEquals(repeat.name(), repeat.toRule().serialize()); + assertEquals(repeat, ReminderRepeat.from(repeat.toRule().serialize())); + } + } } diff --git a/app/src/test/java/com/pasich/mynotes/utils/SearchFilterTest.java b/app/src/test/java/com/pasich/mynotes/utils/SearchFilterTest.java deleted file mode 100644 index 774dc440..00000000 --- a/app/src/test/java/com/pasich/mynotes/utils/SearchFilterTest.java +++ /dev/null @@ -1,75 +0,0 @@ -package com.pasich.mynotes.utils; - -import static com.google.common.truth.Truth.assertThat; - -import com.pasich.mynotes.data.model.Note; -import java.util.Arrays; -import java.util.Date; -import java.util.List; -import java.util.stream.Collectors; -import org.junit.Test; - -public class SearchFilterTest { - - private Note makeNote(String title, String value) { - return new Note().create(title, value, new Date().getTime(), ""); - } - - private List filter(List notes, String query) { - if (query == null || query.trim().isEmpty()) return notes; - String q = query.toLowerCase().trim(); - return notes.stream() - .filter( - n -> - n.getTitle().toLowerCase().contains(q) - || n.getValue().toLowerCase().contains(q)) - .collect(Collectors.toList()); - } - - @Test - public void emptyQuery_returnsAllNotes() { - List notes = Arrays.asList(makeNote("A", "a"), makeNote("B", "b")); - assertThat(filter(notes, "")).hasSize(2); - } - - @Test - public void exactTitleMatch_returnsNote() { - List notes = - Arrays.asList(makeNote("Shopping List", "milk"), makeNote("Work", "tasks")); - assertThat(filter(notes, "Shopping List")).hasSize(1); - } - - @Test - public void partialTitleMatch_returnsNote() { - List notes = - Arrays.asList(makeNote("My Shopping", "items"), makeNote("Work", "tasks")); - assertThat(filter(notes, "Shop")).hasSize(1); - } - - @Test - public void caseInsensitiveSearch_works() { - List notes = - Arrays.asList(makeNote("UPPERCASE", "content"), makeNote("Other", "stuff")); - assertThat(filter(notes, "uppercase")).hasSize(1); - } - - @Test - public void contentSearch_matchesValue() { - List notes = - Arrays.asList( - makeNote("Title", "buy groceries"), makeNote("Title2", "meeting notes")); - assertThat(filter(notes, "groceries")).hasSize(1); - } - - @Test - public void noMatch_returnsEmpty() { - List notes = Arrays.asList(makeNote("Alpha", "beta"), makeNote("Gamma", "delta")); - assertThat(filter(notes, "zzznomatch")).isEmpty(); - } - - @Test - public void nullQuery_returnsAllNotes() { - List notes = Arrays.asList(makeNote("A", "a"), makeNote("B", "b")); - assertThat(filter(notes, null)).hasSize(2); - } -} diff --git a/app/src/test/java/com/pasich/mynotes/utils/UpdateCheckerMeetEnclyTest.java b/app/src/test/java/com/pasich/mynotes/utils/UpdateCheckerMeetEnclyTest.java new file mode 100644 index 00000000..67025568 --- /dev/null +++ b/app/src/test/java/com/pasich/mynotes/utils/UpdateCheckerMeetEnclyTest.java @@ -0,0 +1,70 @@ +package com.pasich.mynotes.utils; + +import static com.google.common.truth.Truth.assertThat; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import android.content.Context; +import android.content.pm.PackageInfo; +import android.content.pm.PackageManager; +import com.pasich.mynotes.cache.AppPreferencesCache; +import com.pasich.mynotes.data.preferences.SafePreferences; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.RuntimeEnvironment; +import org.robolectric.annotation.Config; + +/** The one-time "Meet Encly" sheet: shown on the first start after an update, then never again. */ +@RunWith(RobolectricTestRunner.class) +@Config(sdk = 35) +public class UpdateCheckerMeetEnclyTest { + + private Context app; + + @Before + public void setUp() { + app = RuntimeEnvironment.getApplication(); + } + + /** A fresh checker over the same stored preferences, as after a process restart. */ + private UpdateChecker start(String installedVersion) throws Exception { + PackageInfo info = new PackageInfo(); + info.versionName = installedVersion; + PackageManager packageManager = mock(PackageManager.class); + when(packageManager.getPackageInfo(anyString(), anyInt())).thenReturn(info); + Context context = mock(Context.class); + when(context.getPackageManager()).thenReturn(packageManager); + when(context.getPackageName()).thenReturn("com.pasich.mynotes"); + return new UpdateChecker(context, new AppPreferencesCache(new SafePreferences(app))); + } + + @Test + public void firstStartAfterUpdate_showsOnce_thenNeverAgain() throws Exception { + // The previous version was started and its changelog read. + start("2.6.55").markVersionAsRead(); + + UpdateChecker updated = start("2.7.0"); + assertThat(updated.shouldShowMeetEncly()).isTrue(); + updated.markMeetEnclyShown(); + assertThat(updated.shouldShowMeetEncly()).isFalse(); + + // Restarts, and later updates, never bring it back. + assertThat(start("2.7.0").shouldShowMeetEncly()).isFalse(); + assertThat(start("2.8.0").shouldShowMeetEncly()).isFalse(); + } + + @Test + public void openingThePageFirst_marksTheIntroductionSeen() throws Exception { + // The previous version was started and its changelog read. + start("2.6.55").markVersionAsRead(); + + // MeetEnclyActivity marks it on open, before MainActivity could show the sheet. + start("2.7.0").markMeetEnclyShown(); + + assertThat(start("2.7.0").shouldShowMeetEncly()).isFalse(); + } +} diff --git a/app/src/test/java/com/pasich/mynotes/utils/adapters/NoteAdapterDragTest.java b/app/src/test/java/com/pasich/mynotes/utils/adapters/NoteAdapterDragTest.java new file mode 100644 index 00000000..cd157eff --- /dev/null +++ b/app/src/test/java/com/pasich/mynotes/utils/adapters/NoteAdapterDragTest.java @@ -0,0 +1,89 @@ +package com.pasich.mynotes.utils.adapters; + +import static com.google.common.truth.Truth.assertThat; +import static org.robolectric.Shadows.shadowOf; + +import android.os.Looper; +import androidx.recyclerview.widget.RecyclerView; +import com.pasich.mynotes.data.model.Note; +import com.pasich.mynotes.utils.adapters.notes.NoteAdapter; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.concurrent.atomic.AtomicBoolean; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; + +/** A drag shows its own order, and the dropped order lands without being dispatched twice. */ +@RunWith(RobolectricTestRunner.class) +@Config(sdk = 35) +public class NoteAdapterDragTest { + + private final List events = new ArrayList<>(); + + @Test + public void aDropKeepsTheDraggedOrderWithoutMovingTheCardsAgain() throws Exception { + NoteAdapter adapter = new NoteAdapter(); + adapter.registerAdapterDataObserver( + new RecyclerView.AdapterDataObserver() { + @Override + public void onItemRangeMoved(int from, int to, int count) { + events.add("move " + from + "->" + to); + } + + @Override + public void onItemRangeInserted(int start, int count) { + events.add("insert"); + } + + @Override + public void onItemRangeRemoved(int start, int count) { + events.add("remove"); + } + }); + settle(adapter, Arrays.asList(note(1), note(2), note(3))); + events.clear(); + + adapter.beginDrag(); + adapter.moveDuringDrag(2, 0); + assertThat(ids(adapter.getCurrentList())).containsExactly(3, 1, 2).inOrder(); + assertThat(events).containsExactly("move 2->0"); + + AtomicBoolean committed = new AtomicBoolean(); + adapter.endDrag(() -> committed.set(true)); + await(committed); + + assertThat(adapter.isDragging()).isFalse(); + assertThat(ids(adapter.getCurrentList())).containsExactly(3, 1, 2).inOrder(); + // Only the move made under the finger: the commit itself dispatched nothing. + assertThat(events).containsExactly("move 2->0"); + } + + private void settle(NoteAdapter adapter, List list) throws Exception { + AtomicBoolean done = new AtomicBoolean(); + adapter.submitList(new ArrayList<>(list), () -> done.set(true)); + await(done); + } + + private static void await(AtomicBoolean flag) throws Exception { + for (int i = 0; i < 200 && !flag.get(); i++) { + shadowOf(Looper.getMainLooper()).idle(); + if (!flag.get()) Thread.sleep(10); + } + assertThat(flag.get()).isTrue(); + } + + private static Note note(int id) { + Note note = new Note().create("n" + id, "", id, ""); + note.setId(id); + return note; + } + + private static List ids(List notes) { + List ids = new ArrayList<>(); + for (Note note : notes) ids.add(note.getId()); + return ids; + } +} diff --git a/app/src/test/java/com/pasich/mynotes/utils/editor/EditorCursorTest.java b/app/src/test/java/com/pasich/mynotes/utils/editor/EditorCursorTest.java new file mode 100644 index 00000000..90950c61 --- /dev/null +++ b/app/src/test/java/com/pasich/mynotes/utils/editor/EditorCursorTest.java @@ -0,0 +1,101 @@ +package com.pasich.mynotes.utils.editor; + +import static com.google.common.truth.Truth.assertThat; + +import org.junit.Test; + +public class EditorCursorTest { + + private static final int NONE = EditorCursor.NONE; + + @Test + public void restoredSelectionWinsOverTheViewport() { + int[] selection = EditorCursor.activationSelection(120, 140, 400, 900, 2000); + + assertThat(selection).asList().containsExactly(120, 140).inOrder(); + } + + @Test + public void restoredSelectionIsClampedToTheTextThatIsThere() { + // The note got shorter (sync, another device) while the screen was gone. + int[] selection = EditorCursor.activationSelection(500, 700, NONE, NONE, 300); + + assertThat(selection).asList().containsExactly(300, 300).inOrder(); + } + + @Test + public void reversedRestoredSelectionIsNormalised() { + int[] selection = EditorCursor.activationSelection(40, 10, NONE, NONE, 100); + + assertThat(selection).asList().containsExactly(10, 40).inOrder(); + } + + @Test + public void restoredCaretWithoutEndIsACaret() { + int[] selection = EditorCursor.activationSelection(25, NONE, NONE, NONE, 100); + + assertThat(selection).asList().containsExactly(25, 25).inOrder(); + } + + @Test + public void longNoteReadInTheMiddleKeepsTheCaretOnScreen() { + int[] selection = EditorCursor.activationSelection(NONE, NONE, 1200, 1800, 5000); + + assertThat(selection).asList().containsExactly(1200, 1200).inOrder(); + } + + @Test + public void noteWhoseEndIsVisibleIsContinuedAtItsEnd() { + int[] selection = EditorCursor.activationSelection(NONE, NONE, 0, 80, 80); + + assertThat(selection).asList().containsExactly(80, 80).inOrder(); + } + + @Test + public void unknownViewportFallsBackToTheEnd() { + int[] selection = EditorCursor.activationSelection(NONE, NONE, NONE, NONE, 42); + + assertThat(selection).asList().containsExactly(42, 42).inOrder(); + } + + @Test + public void emptyNoteGetsOffsetZero() { + int[] selection = EditorCursor.activationSelection(NONE, NONE, 0, 0, 0); + + assertThat(selection).asList().containsExactly(0, 0).inOrder(); + } + + @Test + public void clampKeepsOffsetsInsideTheText() { + assertThat(EditorCursor.clamp(-5, 10)).isEqualTo(0); + assertThat(EditorCursor.clamp(15, 10)).isEqualTo(10); + assertThat(EditorCursor.clamp(7, 10)).isEqualTo(7); + assertThat(EditorCursor.clamp(3, 0)).isEqualTo(0); + } + + @Test + public void shortStillPressIsATap() { + assertThat(EditorCursor.isTap(3f, 4f, 120, 8, 400)).isTrue(); + } + + @Test + public void dragIsNotATap() { + assertThat(EditorCursor.isTap(30f, 2f, 120, 8, 400)).isFalse(); + } + + @Test + public void longPressIsNotATap() { + // A long press selects a word; it must never open the link under it. + assertThat(EditorCursor.isTap(0f, 0f, 400, 8, 400)).isFalse(); + } + + @Test + public void tapOnLinkOpensItUnlessTheCaretIsAlreadyInside() { + assertThat(EditorCursor.shouldOpenLink(0, 0, 10, 30)).isTrue(); + assertThat(EditorCursor.shouldOpenLink(5, 12, 10, 30)).isTrue(); + assertThat(EditorCursor.shouldOpenLink(15, 15, 10, 30)).isFalse(); + // At the very edge the caret is next to the link, not inside it. + assertThat(EditorCursor.shouldOpenLink(10, 10, 10, 30)).isTrue(); + assertThat(EditorCursor.shouldOpenLink(0, 0, -1, -1)).isFalse(); + } +} diff --git a/app/src/test/java/com/pasich/mynotes/utils/editor/KeyboardBarStateTest.java b/app/src/test/java/com/pasich/mynotes/utils/editor/KeyboardBarStateTest.java new file mode 100644 index 00000000..fd0e81e7 --- /dev/null +++ b/app/src/test/java/com/pasich/mynotes/utils/editor/KeyboardBarStateTest.java @@ -0,0 +1,79 @@ +package com.pasich.mynotes.utils.editor; + +import static com.google.common.truth.Truth.assertThat; + +import org.junit.Test; + +public class KeyboardBarStateTest { + + @Test + public void hiddenUntilEditingWithTheKeyboardUp() { + KeyboardBarState state = new KeyboardBarState(); + assertThat(state.isShown()).isFalse(); + + assertThat(state.setImeVisible(true)).isFalse(); + assertThat(state.isShown()).isFalse(); + + assertThat(state.setEditing(true)).isTrue(); + assertThat(state.isShown()).isTrue(); + } + + @Test + public void readingHidesTheBarEvenWithTheKeyboardUp() { + KeyboardBarState state = new KeyboardBarState(); + state.setEditing(true); + state.setImeVisible(true); + + assertThat(state.setEditing(false)).isTrue(); + + assertThat(state.isShown()).isFalse(); + } + + @Test + public void noOnScreenKeyboardMeansNoBar() { + // A hardware keyboard: editing, but no IME on screen. + KeyboardBarState state = new KeyboardBarState(); + state.setEditing(true); + + assertThat(state.isShown()).isFalse(); + assertThat(state.setImeVisible(true)).isTrue(); + assertThat(state.setImeVisible(false)).isTrue(); + assertThat(state.isShown()).isFalse(); + } + + @Test + public void reportsOnlyRealChanges() { + KeyboardBarState state = new KeyboardBarState(); + state.setEditing(true); + state.setImeVisible(true); + + assertThat(state.setEditing(true)).isFalse(); + assertThat(state.setImeVisible(true)).isFalse(); + assertThat(state.setHistory(false, false)).isFalse(); + assertThat(state.setHistory(true, false)).isTrue(); + assertThat(state.setHistory(true, false)).isFalse(); + } + + @Test + public void undoAndRedoFollowTheHistoryWhileEditing() { + KeyboardBarState state = new KeyboardBarState(); + state.setEditing(true); + + state.setHistory(true, false); + assertThat(state.isUndoEnabled()).isTrue(); + assertThat(state.isRedoEnabled()).isFalse(); + + state.setHistory(false, true); + assertThat(state.isUndoEnabled()).isFalse(); + assertThat(state.isRedoEnabled()).isTrue(); + } + + @Test + public void undoAndRedoAreOffWhileReading() { + KeyboardBarState state = new KeyboardBarState(); + state.setHistory(true, true); + + assertThat(state.isUndoEnabled()).isFalse(); + assertThat(state.isRedoEnabled()).isFalse(); + } +} diff --git a/app/src/test/java/com/pasich/mynotes/utils/editor/KeyboardRequestTest.java b/app/src/test/java/com/pasich/mynotes/utils/editor/KeyboardRequestTest.java new file mode 100644 index 00000000..a9bc6bdc --- /dev/null +++ b/app/src/test/java/com/pasich/mynotes/utils/editor/KeyboardRequestTest.java @@ -0,0 +1,79 @@ +package com.pasich.mynotes.utils.editor; + +import static com.google.common.truth.Truth.assertThat; + +import com.pasich.mynotes.utils.editor.KeyboardRequest.Step; +import org.junit.Test; + +public class KeyboardRequestTest { + + @Test + public void withoutARequest_nothingHappens() { + assertThat(new KeyboardRequest().next(false, true, true)).isEqualTo(Step.DONE); + } + + @Test + public void aRequestBeforeTheWindowHasFocus_waitsForIt() { + KeyboardRequest request = new KeyboardRequest(); + request.start(); + + assertThat(request.next(false, false, true)).isEqualTo(Step.WAIT_FOR_WINDOW_FOCUS); + assertThat(request.isPending()).isTrue(); + assertThat(request.next(false, true, true)).isEqualTo(Step.CONNECT_AND_SHOW); + } + + @Test + public void aDroppedRequest_isRepeatedUntilTheKeyboardIsUp() { + KeyboardRequest request = new KeyboardRequest(); + request.start(); + + assertThat(request.next(false, true, true)).isEqualTo(Step.CONNECT_AND_SHOW); + assertThat(request.next(false, true, true)).isEqualTo(Step.SHOW_AND_RETRY); + assertThat(request.next(true, true, true)).isEqualTo(Step.DONE); + assertThat(request.isPending()).isFalse(); + } + + @Test + public void aKeyboardUpBeforeTheEditableHasFocus_isConnectedOnceItHas() { + KeyboardRequest request = new KeyboardRequest(); + request.start(); + + // The keyboard is on screen but the page's field has no focus: it is attached to nothing, + // so it does not count, and nothing is asked until the field has the focus. + assertThat(request.next(true, true, false)).isEqualTo(Step.FOCUS_AND_RETRY); + assertThat(request.next(true, true, false)).isEqualTo(Step.FOCUS_AND_RETRY); + assertThat(request.next(true, true, true)).isEqualTo(Step.CONNECT_AND_SHOW); + assertThat(request.next(true, true, true)).isEqualTo(Step.DONE); + } + + @Test + public void anEditableThatLosesFocus_isConnectedAgain() { + KeyboardRequest request = new KeyboardRequest(); + request.start(); + + assertThat(request.next(false, true, true)).isEqualTo(Step.CONNECT_AND_SHOW); + assertThat(request.next(false, true, false)).isEqualTo(Step.FOCUS_AND_RETRY); + assertThat(request.next(false, true, true)).isEqualTo(Step.CONNECT_AND_SHOW); + } + + @Test + public void aKeyboardThatNeverComes_isGivenUpOn() { + KeyboardRequest request = new KeyboardRequest(); + request.start(); + + assertThat(request.next(false, true, true)).isEqualTo(Step.CONNECT_AND_SHOW); + for (int i = 1; i < KeyboardRequest.ATTEMPTS; i++) { + assertThat(request.next(false, true, true)).isEqualTo(Step.SHOW_AND_RETRY); + } + assertThat(request.next(false, true, true)).isEqualTo(Step.DONE); + } + + @Test + public void aCancelledRequest_stops() { + KeyboardRequest request = new KeyboardRequest(); + request.start(); + request.cancel(); + + assertThat(request.next(false, true, true)).isEqualTo(Step.DONE); + } +} diff --git a/app/src/test/java/com/pasich/mynotes/utils/editor/NoteViewStateStoreTest.java b/app/src/test/java/com/pasich/mynotes/utils/editor/NoteViewStateStoreTest.java new file mode 100644 index 00000000..b6c8cf2a --- /dev/null +++ b/app/src/test/java/com/pasich/mynotes/utils/editor/NoteViewStateStoreTest.java @@ -0,0 +1,138 @@ +package com.pasich.mynotes.utils.editor; + +import static com.google.common.truth.Truth.assertThat; + +import android.content.Context; +import java.util.Arrays; +import java.util.HashMap; +import java.util.Map; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.RuntimeEnvironment; +import org.robolectric.annotation.Config; + +@RunWith(RobolectricTestRunner.class) +@Config(sdk = 35) +public class NoteViewStateStoreTest { + + private Context context; + private long now = 1_000L; + private NoteViewStateStore store; + + @Before + public void setUp() { + context = RuntimeEnvironment.getApplication(); + store = new NoteViewStateStore(context, () -> now++); + store.clear(); + } + + private static NoteViewState.Simple simple(int caret) { + return PositionRestorer.captureSimple("some text in a note body", caret, caret, 0, 0, 0f); + } + + @Test + public void positionSurvivesANewInstance() { + store.putSimple(7, simple(5)); + + NoteViewState read = new NoteViewStateStore(context).get(7); + + assertThat(read).isNotNull(); + assertThat(read.simple).isNotNull(); + assertThat(read.simple.selectionStart).isEqualTo(5); + } + + @Test + public void eachEditorKeepsItsOwnSection() { + store.putSimple(3, simple(2)); + NoteViewState.Extended extended = new NoteViewState.Extended(); + extended.caretBlockId = "block"; + store.putExtended(3, extended); + + NoteViewState read = store.get(3); + + assertThat(read.simple.selectionStart).isEqualTo(2); + assertThat(read.extended.caretBlockId).isEqualTo("block"); + } + + @Test + public void removeForgetsOnlyThatNote() { + store.putSimple(1, simple(1)); + store.putSimple(2, simple(2)); + + store.remove(1); + + assertThat(store.get(1)).isNull(); + assertThat(store.get(2)).isNotNull(); + } + + @Test + public void removeAllForgetsEveryListedNote() { + store.putSimple(1, simple(1)); + store.putSimple(2, simple(2)); + store.putSimple(3, simple(3)); + + store.removeAll(Arrays.asList(1, 3)); + + assertThat(store.get(1)).isNull(); + assertThat(store.get(2)).isNotNull(); + assertThat(store.get(3)).isNull(); + } + + @Test + public void keepsOnlyTheMostRecentlyUsedNotes() { + for (int id = 1; id <= NoteViewStateStore.MAX_ENTRIES + 5; id++) { + store.putSimple(id, simple(1)); + } + + assertThat(store.get(1)).isNull(); + assertThat(store.get(5)).isNull(); + assertThat(store.get(6)).isNotNull(); + assertThat(store.get(NoteViewStateStore.MAX_ENTRIES + 5)).isNotNull(); + } + + @Test + public void rewritingAnOldEntryMakesItRecent() { + for (int id = 1; id <= NoteViewStateStore.MAX_ENTRIES; id++) { + store.putSimple(id, simple(1)); + } + store.putSimple(1, simple(2)); + + store.putSimple(NoteViewStateStore.MAX_ENTRIES + 1, simple(1)); + + assertThat(store.get(1)).isNotNull(); + assertThat(store.get(2)).isNull(); + } + + @Test + public void unreadableEntryIsTreatedAsMissing() { + context.getSharedPreferences(NoteViewStateStore.FILE, Context.MODE_PRIVATE) + .edit() + .putString("n_9", "{not json") + .commit(); + + assertThat(store.get(9)).isNull(); + } + + @Test + public void invalidIdsAreIgnored() { + store.putSimple(0, simple(1)); + + assertThat(store.get(0)).isNull(); + assertThat(store.contains(0)).isFalse(); + } + + @Test + public void leastRecentlyUsed_ordersByTimeThenKey() { + Map usedAt = new HashMap<>(); + usedAt.put("n_3", 30L); + usedAt.put("n_1", 10L); + usedAt.put("n_2", 10L); + + assertThat(NoteViewStateStore.leastRecentlyUsed(usedAt, 2)) + .containsExactly("n_1", "n_2") + .inOrder(); + assertThat(NoteViewStateStore.leastRecentlyUsed(usedAt, 0)).isEmpty(); + } +} diff --git a/app/src/test/java/com/pasich/mynotes/utils/editor/PositionRestorerTest.java b/app/src/test/java/com/pasich/mynotes/utils/editor/PositionRestorerTest.java new file mode 100644 index 00000000..e4084102 --- /dev/null +++ b/app/src/test/java/com/pasich/mynotes/utils/editor/PositionRestorerTest.java @@ -0,0 +1,290 @@ +package com.pasich.mynotes.utils.editor; + +import static com.google.common.truth.Truth.assertThat; + +import java.util.Arrays; +import java.util.Collections; +import org.junit.Test; + +public class PositionRestorerTest { + + private static final int NONE = EditorCursor.NONE; + + /** A long note whose paragraphs are all different, so any 32 characters occur once. */ + private static String longNote() { + StringBuilder text = new StringBuilder(); + for (int i = 0; i < 60; i++) { + text.append("Paragraph ").append(i).append(" talks about item number ").append(i * 7); + text.append(".\n"); + } + return text.toString(); + } + + // --------------------------------------------------------------------------------------- + // Simple editor + // --------------------------------------------------------------------------------------- + + @Test + public void unchangedText_restoresExactly() { + String text = longNote(); + NoteViewState.Simple saved = PositionRestorer.captureSimple(text, 900, 910, 800, 1200, .4f); + + PositionRestorer.SimpleTarget target = PositionRestorer.restoreSimple(saved, text); + + assertThat(target.match).isEqualTo(PositionRestorer.Match.EXACT); + assertThat(target.selectionStart).isEqualTo(900); + assertThat(target.selectionEnd).isEqualTo(910); + assertThat(target.topOffset).isEqualTo(800); + } + + @Test + public void textInsertedAbove_followsTheTextAroundTheCaret() { + String text = longNote(); + NoteViewState.Simple saved = PositionRestorer.captureSimple(text, 900, 900, 800, 0, 0f); + String inserted = "A new first line written on another device.\n"; + + PositionRestorer.SimpleTarget target = + PositionRestorer.restoreSimple(saved, inserted + text); + + assertThat(target.match).isEqualTo(PositionRestorer.Match.ANCHOR); + assertThat(target.selectionStart).isEqualTo(900 + inserted.length()); + assertThat(target.selectionEnd).isEqualTo(target.selectionStart); + assertThat(target.topOffset).isEqualTo(800 + inserted.length()); + } + + @Test + public void textAroundTheCaretEdited_findsItByTheSideThatSurvived() { + String text = longNote(); + int caret = 900; + NoteViewState.Simple saved = + PositionRestorer.captureSimple(text, caret, caret, NONE, 0, 0f); + // The words just before the caret were rewritten; the text after it is untouched. + String edited = text.substring(0, caret - 10) + "REWRITTEN!" + text.substring(caret); + + PositionRestorer.SimpleTarget target = PositionRestorer.restoreSimple(saved, edited); + + assertThat(target.match).isEqualTo(PositionRestorer.Match.ANCHOR); + assertThat(target.selectionStart).isEqualTo(caret); + } + + @Test + public void repeatedContext_picksTheOccurrenceNearestTheOldPosition() { + String block = "same words over and over again here\n"; + StringBuilder text = new StringBuilder(); + for (int i = 0; i < 20; i++) text.append(block); + int caret = block.length() * 12; + NoteViewState.Simple saved = + PositionRestorer.captureSimple(text.toString(), caret, caret, NONE, 0, 0f); + + PositionRestorer.SimpleTarget target = PositionRestorer.restoreSimple(saved, "x" + text); + + assertThat(target.selectionStart).isEqualTo(caret + 1); + } + + @Test + public void shortenedText_withContextGone_isClampedIntoRange() { + String text = longNote(); + NoteViewState.Simple saved = + PositionRestorer.captureSimple(text, text.length(), text.length(), 2000, 0, 0f); + // A modest change that removed the end of the note, including the caret's context. + String shorter = text.substring(0, (int) (text.length() * 0.7)); + + PositionRestorer.SimpleTarget target = PositionRestorer.restoreSimple(saved, shorter); + + assertThat(target.match).isEqualTo(PositionRestorer.Match.CLAMPED); + assertThat(target.selectionStart).isAtLeast(0); + assertThat(target.selectionStart).isAtMost(shorter.length()); + assertThat(target.topOffset).isAtMost(shorter.length()); + } + + @Test + public void replacedNote_opensAtTheTop() { + String text = longNote(); + NoteViewState.Simple saved = PositionRestorer.captureSimple(text, 900, 900, 800, 0, 0f); + + PositionRestorer.SimpleTarget target = + PositionRestorer.restoreSimple(saved, "Completely different and short."); + + assertThat(target.match).isEqualTo(PositionRestorer.Match.TOP); + assertThat(target.hasSelection()).isFalse(); + assertThat(target.topOffset).isEqualTo(NONE); + } + + @Test + public void emptiedNote_opensAtTheTop() { + NoteViewState.Simple saved = PositionRestorer.captureSimple(longNote(), 50, 50, 40, 0, 0f); + + assertThat(PositionRestorer.restoreSimple(saved, "").match) + .isEqualTo(PositionRestorer.Match.TOP); + } + + @Test + public void nothingSaved_opensAtTheTop() { + assertThat(PositionRestorer.restoreSimple(null, longNote()).match) + .isEqualTo(PositionRestorer.Match.TOP); + } + + @Test + public void titleOnScreenAndNotEditing_opensAtTheTop() { + NoteViewState.Simple saved = + PositionRestorer.captureSimple(longNote(), NONE, NONE, NONE, 0, 0f); + + assertThat(PositionRestorer.restoreSimple(saved, longNote()).match) + .isEqualTo(PositionRestorer.Match.TOP); + } + + @Test + public void readingOnly_restoresTheViewportWithoutACaret() { + String text = longNote(); + NoteViewState.Simple saved = PositionRestorer.captureSimple(text, NONE, NONE, 1500, 0, 0f); + + PositionRestorer.SimpleTarget target = PositionRestorer.restoreSimple(saved, text); + + assertThat(target.hasSelection()).isFalse(); + assertThat(target.topOffset).isEqualTo(1500); + } + + @Test + public void caretFoundButTopContextGone_viewportFollowsTheCaret() { + String text = longNote(); + NoteViewState.Simple saved = PositionRestorer.captureSimple(text, 900, 900, 600, 0, 0f); + // Everything between the top of the screen and just before the caret was rewritten. + String edited = text.substring(0, 560) + "#".repeat(300) + text.substring(860); + + PositionRestorer.SimpleTarget target = PositionRestorer.restoreSimple(saved, edited); + + assertThat(target.match).isEqualTo(PositionRestorer.Match.ANCHOR); + assertThat(target.topOffset).isEqualTo(target.selectionStart); + } + + @Test + public void tamperedOffsetsAreNeverOutOfRange() { + String text = longNote(); + NoteViewState.Simple saved = PositionRestorer.captureSimple(text, 10, 10, 10, 0, 0f); + // A stored entry from an older build or a corrupted file. + saved.selectionStart = 1_000_000; + saved.selectionEnd = 2_000_000; + saved.topOffset = 3_000_000; + + PositionRestorer.SimpleTarget target = PositionRestorer.restoreSimple(saved, text); + + assertThat(target.selectionStart).isAtMost(text.length()); + assertThat(target.selectionEnd).isAtMost(text.length()); + assertThat(target.topOffset).isAtMost(text.length()); + } + + @Test + public void offsetsNeverSplitASurrogatePair() { + String text = "start 😀 end of a reasonably long line of text"; + int insideEmoji = text.indexOf("😀") + 1; + + assertThat(PositionRestorer.snap(text, insideEmoji)).isEqualTo(insideEmoji - 1); + } + + @Test + public void captureNormalisesAReversedSelection() { + NoteViewState.Simple saved = + PositionRestorer.captureSimple(longNote(), 40, 10, NONE, -5, 2f); + + assertThat(saved.selectionStart).isEqualTo(10); + assertThat(saved.selectionEnd).isEqualTo(40); + assertThat(saved.scrollY).isEqualTo(0); + assertThat(saved.scrollRatio).isEqualTo(1f); + } + + @Test + public void hashIsStableAndSensitive() { + assertThat(PositionRestorer.hash("note")).isEqualTo(PositionRestorer.hash("note")); + assertThat(PositionRestorer.hash("note")).isNotEqualTo(PositionRestorer.hash("nose")); + } + + // --------------------------------------------------------------------------------------- + // Extended editor + // --------------------------------------------------------------------------------------- + + private static NoteViewState.Extended extended( + String caretId, int caretOffset, String topId, int topIndex, int topPx) { + NoteViewState.Extended state = new NoteViewState.Extended(); + state.caretBlockId = caretId; + state.caretOffset = caretOffset; + state.topBlockId = topId; + state.topBlockIndex = topIndex; + state.topOffsetPx = topPx; + return state; + } + + @Test + public void extended_blocksStillThere_restoresExactly() { + PositionRestorer.ExtendedTarget target = + PositionRestorer.restoreExtended( + extended("c", 12, "b", 1, -40), Arrays.asList("a", "b", "c")); + + assertThat(target.match).isEqualTo(PositionRestorer.Match.EXACT); + assertThat(target.caretBlockId).isEqualTo("c"); + assertThat(target.caretOffset).isEqualTo(12); + assertThat(target.topBlockId).isEqualTo("b"); + assertThat(target.topOffsetPx).isEqualTo(-40); + } + + @Test + public void extended_blocksMoved_areFoundById() { + PositionRestorer.ExtendedTarget target = + PositionRestorer.restoreExtended( + extended("c", 3, "b", 1, 0), Arrays.asList("new", "x", "y", "b", "c")); + + assertThat(target.topBlockId).isEqualTo("b"); + assertThat(target.topBlockIndex).isEqualTo(3); + } + + @Test + public void extended_topBlockDeleted_viewportFollowsTheCaret() { + PositionRestorer.ExtendedTarget target = + PositionRestorer.restoreExtended( + extended("c", 3, "gone", 1, 0), Arrays.asList("a", "c")); + + assertThat(target.match).isEqualTo(PositionRestorer.Match.ANCHOR); + assertThat(target.caretBlockId).isEqualTo("c"); + assertThat(target.topBlockId).isNull(); + } + + @Test + public void extended_allBlocksReplaced_keepsTheIndexClampedWithoutACaret() { + PositionRestorer.ExtendedTarget target = + PositionRestorer.restoreExtended( + extended("old1", 3, "old2", 9, 20), Arrays.asList("n1", "n2", "n3")); + + assertThat(target.match).isEqualTo(PositionRestorer.Match.CLAMPED); + assertThat(target.caretBlockId).isNull(); + assertThat(target.topBlockIndex).isEqualTo(2); + } + + @Test + public void extended_emptyNote_opensAtTheTop() { + assertThat( + PositionRestorer.restoreExtended( + extended("a", 1, "a", 0, 0), Collections.emptyList()) + .match) + .isEqualTo(PositionRestorer.Match.TOP); + } + + @Test + public void extended_titleOnScreenAndReading_opensAtTheTop() { + assertThat( + PositionRestorer.restoreExtended( + extended(null, 0, null, -1, 0), Arrays.asList("a")) + .match) + .isEqualTo(PositionRestorer.Match.TOP); + } + + @Test + public void extended_negativeOffsetsAreClamped() { + NoteViewState.Extended saved = extended("a", -7, null, -1, 0); + saved.caretInput = -2; + + PositionRestorer.ExtendedTarget target = + PositionRestorer.restoreExtended(saved, Arrays.asList("a")); + + assertThat(target.caretOffset).isEqualTo(0); + assertThat(target.caretInput).isEqualTo(0); + } +} diff --git a/app/src/test/java/com/pasich/mynotes/utils/editor/RetainedEditHistoryTest.java b/app/src/test/java/com/pasich/mynotes/utils/editor/RetainedEditHistoryTest.java new file mode 100644 index 00000000..5d60a5bb --- /dev/null +++ b/app/src/test/java/com/pasich/mynotes/utils/editor/RetainedEditHistoryTest.java @@ -0,0 +1,34 @@ +package com.pasich.mynotes.utils.editor; + +import static com.google.common.truth.Truth.assertThat; + +import org.junit.Test; + +public class RetainedEditHistoryTest { + + @Test + public void theHistoryIsTakenOnceForItsNote() { + RetainedEditHistory retained = new RetainedEditHistory(); + retained.put(7, "{\"current\":{}}"); + + assertThat(retained.take(7)).isEqualTo("{\"current\":{}}"); + assertThat(retained.take(7)).isNull(); + } + + @Test + public void anotherNote_getsNothingAndTheHistoryIsForgotten() { + RetainedEditHistory retained = new RetainedEditHistory(); + retained.put(7, "{}"); + + assertThat(retained.take(8)).isNull(); + assertThat(retained.take(7)).isNull(); + } + + @Test + public void aNoteWithoutAnId_getsNothing() { + RetainedEditHistory retained = new RetainedEditHistory(); + retained.put(0, "{}"); + + assertThat(retained.take(0)).isNull(); + } +} diff --git a/app/src/test/java/com/pasich/mynotes/utils/editor/TextEditHistoryTest.java b/app/src/test/java/com/pasich/mynotes/utils/editor/TextEditHistoryTest.java new file mode 100644 index 00000000..fb23a031 --- /dev/null +++ b/app/src/test/java/com/pasich/mynotes/utils/editor/TextEditHistoryTest.java @@ -0,0 +1,435 @@ +package com.pasich.mynotes.utils.editor; + +import static com.google.common.truth.Truth.assertThat; +import static com.pasich.mynotes.utils.editor.TextEditHistory.FIELD_BODY; +import static com.pasich.mynotes.utils.editor.TextEditHistory.FIELD_TITLE; + +import java.util.ArrayList; +import java.util.List; +import org.junit.Before; +import org.junit.Test; + +/** + * The simple editor's undo history, driven the way the text fields drive it: every change reports + * where it starts, what it removed and what it put in. + */ +public class TextEditHistoryTest { + + private static final long WINDOW = TextEditHistory.DEFAULT_MERGE_WINDOW_MS; + + private TextEditHistory history; + private String title; + private String body; + private long now; + + @Before + public void setUp() { + history = new TextEditHistory(); + title = ""; + body = ""; + now = 1_000; + } + + // -- helpers that edit the "fields" and record like the watchers do -------------------------- + + private void replace(int field, int start, int end, String text, long at) { + String current = field == FIELD_TITLE ? title : body; + String removed = current.substring(start, end); + String updated = current.substring(0, start) + text + current.substring(end); + if (field == FIELD_TITLE) title = updated; + else body = updated; + history.record(field, start, removed, text, start, end, at); + } + + private void type(String text) { + for (char c : text.toCharArray()) { + now += 50; + replace(FIELD_BODY, body.length(), body.length(), String.valueOf(c), now); + } + } + + private void backspace(int times) { + for (int i = 0; i < times; i++) { + now += 50; + replace(FIELD_BODY, body.length() - 1, body.length(), "", now); + } + } + + private TextEditHistory.Change undo() { + return apply(history.undo()); + } + + private TextEditHistory.Change redo() { + return apply(history.redo()); + } + + private TextEditHistory.Change apply(TextEditHistory.Change change) { + if (change == null) return null; + if (change.field == FIELD_TITLE) title = change.applyTo(title); + else body = change.applyTo(body); + return change; + } + + // -- grouping ----------------------------------------------------------------------------- + + @Test + public void emptyHistoryHasNothingToUndoOrRedo() { + assertThat(history.canUndo()).isFalse(); + assertThat(history.canRedo()).isFalse(); + assertThat(history.undo()).isNull(); + assertThat(history.redo()).isNull(); + } + + @Test + public void lettersTypedInARowAreOneStep() { + type("hello"); + + undo(); + + assertThat(body).isEmpty(); + assertThat(history.canUndo()).isFalse(); + } + + @Test + public void eachWordIsItsOwnStepWithTheSpaceAfterIt() { + type("hello world again"); + + undo(); + assertThat(body).isEqualTo("hello world "); + undo(); + assertThat(body).isEqualTo("hello "); + undo(); + assertThat(body).isEmpty(); + } + + @Test + public void aPauseStartsANewStep() { + type("abc"); + now += WINDOW + 1; + type("def"); + + undo(); + + assertThat(body).isEqualTo("abc"); + } + + @Test + public void keyboardRewritingTheComposedWordJoinsTheStep() { + // A keyboard composing a word replaces the whole word on every key. + now += 50; + replace(FIELD_BODY, 0, 0, "h", now); + now += 50; + replace(FIELD_BODY, 0, 1, "he", now); + now += 50; + replace(FIELD_BODY, 0, 2, "hel", now); + now += 50; + replace(FIELD_BODY, 0, 3, "help", now); + + undo(); + + assertThat(body).isEmpty(); + assertThat(history.canUndo()).isFalse(); + } + + @Test + public void correctingATypoWithBackspaceStaysInTheWord() { + type("helo"); + backspace(1); + type("lo"); + + assertThat(body).isEqualTo("hello"); + undo(); + assertThat(body).isEmpty(); + } + + @Test + public void typedAndBackspacedAwayLeavesNothingToUndo() { + type("ab"); + backspace(2); + + assertThat(history.canUndo()).isFalse(); + } + + @Test + public void backspacingThroughOlderTextIsOneStepPerWord() { + body = "one two"; + now += WINDOW + 1; + backspace(4); // "owt " -> "one" + + undo(); + assertThat(body).isEqualTo("one "); + undo(); + assertThat(body).isEqualTo("one two"); + } + + @Test + public void forwardDeleteJoinsTheStep() { + body = "abcdef"; + now += 50; + replace(FIELD_BODY, 1, 2, "", now); + now += 50; + replace(FIELD_BODY, 1, 2, "", now); + + assertThat(body).isEqualTo("adef"); + undo(); + assertThat(body).isEqualTo("abcdef"); + } + + @Test + public void typingAfterADeletionIsASeparateStep() { + body = "abc"; + now += 50; + replace(FIELD_BODY, 2, 3, "", now); + now += 50; + replace(FIELD_BODY, 2, 2, "x", now); + + undo(); + assertThat(body).isEqualTo("ab"); + undo(); + assertThat(body).isEqualTo("abc"); + } + + @Test + public void aPasteIsAStepOfItsOwn() { + type("note"); + now += 50; + replace(FIELD_BODY, 4, 4, " pasted text", now); + + undo(); + + assertThat(body).isEqualTo("note"); + } + + @Test + public void titleAndBodyNeverShareAStep() { + now += 50; + replace(FIELD_TITLE, 0, 0, "T", now); + type("b"); + + TextEditHistory.Change change = undo(); + + assertThat(change.field).isEqualTo(FIELD_BODY); + assertThat(title).isEqualTo("T"); + assertThat(body).isEmpty(); + change = undo(); + assertThat(change.field).isEqualTo(FIELD_TITLE); + assertThat(title).isEmpty(); + } + + @Test + public void sealEndsTheStepBeingTyped() { + type("ab"); + history.seal(); + type("cd"); + + undo(); + + assertThat(body).isEqualTo("ab"); + } + + // -- redo ----------------------------------------------------------------------------------- + + @Test + public void undoneStepsCanBeRedoneInOrder() { + type("one two three"); + undo(); + undo(); + assertThat(body).isEqualTo("one "); + + redo(); + assertThat(body).isEqualTo("one two "); + redo(); + assertThat(body).isEqualTo("one two three"); + assertThat(history.canRedo()).isFalse(); + } + + @Test + public void aNewEditAfterAnUndoMakesRedoUnavailable() { + type("one two"); + undo(); + assertThat(history.canRedo()).isTrue(); + + type("x"); + + assertThat(history.canRedo()).isFalse(); + assertThat(history.redo()).isNull(); + assertThat(body).isEqualTo("one x"); + } + + @Test + public void typingAfterAnUndoDoesNotJoinTheUndoneStep() { + type("ab"); + now += WINDOW + 1; + type("cd"); + undo(); + + type("x"); + undo(); + + assertThat(body).isEqualTo("ab"); + } + + // -- selection -------------------------------------------------------------------------------- + + @Test + public void undoPutsBackTheSelectionThatWasReplaced() { + body = "keep this word"; + now += WINDOW + 1; + // Select "this" (5..9) and type over it. + replace(FIELD_BODY, 5, 9, "t", now); + for (char c : "hat".toCharArray()) { + now += 50; + replace( + FIELD_BODY, + body.indexOf(" word"), + body.indexOf(" word"), + String.valueOf(c), + now); + } + assertThat(body).isEqualTo("keep that word"); + + TextEditHistory.Change change = undo(); + + assertThat(body).isEqualTo("keep this word"); + assertThat(change.selectionStart).isEqualTo(5); + assertThat(change.selectionEnd).isEqualTo(9); + } + + @Test + public void redoPutsTheCaretAfterTheRedoneText() { + type("abc"); + undo(); + + TextEditHistory.Change change = redo(); + + assertThat(body).isEqualTo("abc"); + assertThat(change.selectionStart).isEqualTo(3); + assertThat(change.selectionEnd).isEqualTo(3); + } + + // -- limits --------------------------------------------------------------------------------- + + @Test + public void oldestStepsAreDroppedBeyondTheStepLimit() { + history = new TextEditHistory(3, 1_000, WINDOW); + for (int i = 0; i < 5; i++) { + now += WINDOW + 1; + replace(FIELD_BODY, body.length(), body.length(), String.valueOf(i), now); + } + + int undone = 0; + while (undo() != null) undone++; + + assertThat(undone).isEqualTo(3); + assertThat(body).isEqualTo("01"); + } + + @Test + public void oldestStepsAreDroppedBeyondTheCharacterLimit() { + history = new TextEditHistory(100, 10, WINDOW); + now += WINDOW + 1; + replace(FIELD_BODY, 0, 0, "aaaaaa", now); + now += WINDOW + 1; + replace(FIELD_BODY, 6, 6, "bbbbbb", now); + + undo(); + + assertThat(body).isEqualTo("aaaaaa"); + assertThat(history.canUndo()).isFalse(); + } + + @Test + public void theNewestStepIsKeptEvenWhenItAloneIsOverTheLimit() { + history = new TextEditHistory(100, 4, WINDOW); + now += 50; + replace(FIELD_BODY, 0, 0, "longer than four", now); + + assertThat(history.canUndo()).isTrue(); + } + + @Test + public void clearForgetsEverything() { + type("one two"); + undo(); + + history.clear(); + + assertThat(history.canUndo()).isFalse(); + assertThat(history.canRedo()).isFalse(); + } + + // -- listener ------------------------------------------------------------------------------- + + @Test + public void listenerHearsOnlyWhenAvailabilityChanges() { + List states = new ArrayList<>(); + history.setListener((canUndo, canRedo) -> states.add(canUndo + "," + canRedo)); + + type("abc"); + undo(); + redo(); + + assertThat(states) + .containsExactly("false,false", "true,false", "false,true", "true,false") + .inOrder(); + } + + // -- saved state ------------------------------------------------------------------------------ + + @Test + public void snapshotRestoresIntoTheSameText() { + type("one two three"); + undo(); + TextEditHistory.Snapshot snapshot = history.snapshot(10_000, title, body); + + TextEditHistory restored = new TextEditHistory(); + assertThat(restored.restore(snapshot, title, body)).isTrue(); + history = restored; + + redo(); + assertThat(body).isEqualTo("one two three"); + undo(); + undo(); + assertThat(body).isEqualTo("one "); + } + + @Test + public void snapshotIsRefusedForDifferentText() { + type("one two"); + TextEditHistory.Snapshot snapshot = history.snapshot(10_000, title, body); + + TextEditHistory restored = new TextEditHistory(); + + assertThat(restored.restore(snapshot, title, "something else")).isFalse(); + assertThat(restored.canUndo()).isFalse(); + } + + @Test + public void snapshotKeepsTheMostRecentStepsWithinItsBudget() { + type("aaaa bbbb cccc"); + TextEditHistory.Snapshot snapshot = history.snapshot(10, title, body); + + assertThat(snapshot.fields.length).isEqualTo(2); + TextEditHistory restored = new TextEditHistory(); + restored.restore(snapshot, title, body); + history = restored; + undo(); + undo(); + assertThat(body).isEqualTo("aaaa "); + assertThat(history.canUndo()).isFalse(); + } + + @Test + public void restoredStepsDoNotJoinNewTyping() { + type("ab"); + TextEditHistory.Snapshot snapshot = history.snapshot(10_000, title, body); + TextEditHistory restored = new TextEditHistory(); + restored.restore(snapshot, title, body); + history = restored; + + type("c"); + undo(); + + assertThat(body).isEqualTo("ab"); + } +} diff --git a/app/src/test/java/com/pasich/mynotes/utils/encly/EnclyReceiverCheckTest.java b/app/src/test/java/com/pasich/mynotes/utils/encly/EnclyReceiverCheckTest.java new file mode 100644 index 00000000..e03bc32d --- /dev/null +++ b/app/src/test/java/com/pasich/mynotes/utils/encly/EnclyReceiverCheckTest.java @@ -0,0 +1,186 @@ +package com.pasich.mynotes.utils.encly; + +import static com.google.common.truth.Truth.assertThat; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import org.junit.Test; + +public class EnclyReceiverCheckTest { + + private static final String ACTIVITY = "com.pasich.encly.ImportFromMyNotesActivity"; + + /** A certificate this test owns, so the legacy path can be shown to accept a real match. */ + private static final byte[] TEST_CERT = "test certificate".getBytes(StandardCharsets.UTF_8); + + private static final String TEST_PIN = hex(EnclyReceiverCheck.sha256(TEST_CERT)); + private static final byte[] OTHER_CERT = "someone else".getBytes(StandardCharsets.UTF_8); + + private static final class FakeInspector implements EnclyReceiverCheck.Inspector { + int sdk = 34; + boolean installed = true; + String activity = ACTIVITY; + boolean modernMatch = true; + List legacy; + byte[] askedSha; + String askedAction; + String askedPackage; + + @Override + public int sdkInt() { + return sdk; + } + + @Override + public boolean isInstalled(@NonNull String packageName) { + askedPackage = packageName; + return installed; + } + + @Nullable + @Override + public String findActivity(@NonNull String packageName, @NonNull String action) { + askedAction = action; + return activity; + } + + @Override + public boolean hasSigningCertificateSha256( + @NonNull String packageName, @NonNull byte[] sha256) { + askedSha = sha256; + return modernMatch; + } + + @Nullable + @Override + public List legacySignatures(@NonNull String packageName) { + return legacy; + } + } + + private static FakeInspector legacy(int sdk, byte[]... signers) { + FakeInspector inspector = new FakeInspector(); + inspector.sdk = sdk; + List list = new ArrayList<>(); + for (byte[] signer : signers) list.add(signer); + inspector.legacy = list; + return inspector; + } + + @Test + public void notInstalled() { + FakeInspector inspector = new FakeInspector(); + inspector.installed = false; + + EnclyReceiverCheck.Result result = EnclyReceiverCheck.check(inspector); + + assertThat(result.status).isEqualTo(EnclyReceiverCheck.Status.NOT_INSTALLED); + assertThat(result.activityClassName).isNull(); + assertThat(inspector.askedPackage).isEqualTo("com.pasich.encly"); + } + + @Test + public void ready_onApi28Plus_asksForThePinnedEnclyDigestAndTheImportAction() { + FakeInspector inspector = new FakeInspector(); + + EnclyReceiverCheck.Result result = EnclyReceiverCheck.check(inspector); + + assertThat(result.status).isEqualTo(EnclyReceiverCheck.Status.READY); + assertThat(result.activityClassName).isEqualTo(ACTIVITY); + assertThat(hex(inspector.askedSha)) + .isEqualTo("6884c693354964276231e6d0336b965f690bf3f15a0707bb59505c208e5e7554"); + assertThat(inspector.askedAction).isEqualTo("com.pasich.encly.action.IMPORT_FROM_MY_NOTES"); + } + + @Test + public void certificateMismatch_isUntrusted_evenWhenTheActionResolves() { + FakeInspector inspector = new FakeInspector(); + inspector.modernMatch = false; + + EnclyReceiverCheck.Result result = EnclyReceiverCheck.check(inspector); + + assertThat(result.status).isEqualTo(EnclyReceiverCheck.Status.UNTRUSTED); + assertThat(result.activityClassName).isNull(); + } + + @Test + public void actionDoesNotResolve_needsUpdate() { + FakeInspector inspector = new FakeInspector(); + inspector.activity = null; + + EnclyReceiverCheck.Result result = EnclyReceiverCheck.check(inspector); + + assertThat(result.status).isEqualTo(EnclyReceiverCheck.Status.NEEDS_UPDATE); + assertThat(result.activityClassName).isNull(); + } + + @Test + public void api27_singleSignerWithThePinnedDigest_isReady() { + FakeInspector inspector = legacy(27, TEST_CERT); + inspector.modernMatch = false; // must not be consulted below API 28 + + EnclyReceiverCheck.Result result = EnclyReceiverCheck.check(inspector, TEST_PIN); + + assertThat(result.status).isEqualTo(EnclyReceiverCheck.Status.READY); + assertThat(inspector.askedSha).isNull(); + } + + @Test + public void api26_singleSignerWithAnotherDigest_isUntrusted() { + FakeInspector inspector = legacy(26, OTHER_CERT); + inspector.modernMatch = true; // would wrongly pass if consulted + + assertThat(EnclyReceiverCheck.check(inspector, TEST_PIN).status) + .isEqualTo(EnclyReceiverCheck.Status.UNTRUSTED); + assertThat(inspector.askedSha).isNull(); + } + + @Test + public void api27_realPin_rejectsAnUnknownCertificate() { + assertThat(EnclyReceiverCheck.check(legacy(27, TEST_CERT)).status) + .isEqualTo(EnclyReceiverCheck.Status.UNTRUSTED); + } + + @Test + public void api27_moreThanOneSigner_isUntrusted_evenIfOneMatches() { + FakeInspector inspector = legacy(27, TEST_CERT, OTHER_CERT); + + assertThat(EnclyReceiverCheck.check(inspector, TEST_PIN).status) + .isEqualTo(EnclyReceiverCheck.Status.UNTRUSTED); + } + + @Test + public void api27_unreadableOrEmptySignatures_isUntrusted() { + FakeInspector none = new FakeInspector(); + none.sdk = 27; + none.legacy = null; + assertThat(EnclyReceiverCheck.check(none, TEST_PIN).status) + .isEqualTo(EnclyReceiverCheck.Status.UNTRUSTED); + + assertThat(EnclyReceiverCheck.check(legacy(27), TEST_PIN).status) + .isEqualTo(EnclyReceiverCheck.Status.UNTRUSTED); + + assertThat(EnclyReceiverCheck.check(legacy(27, (byte[]) null), TEST_PIN).status) + .isEqualTo(EnclyReceiverCheck.Status.UNTRUSTED); + } + + @Test + public void certificateIsCheckedBeforeTheActionIsTrusted() { + FakeInspector inspector = legacy(27, OTHER_CERT); + inspector.activity = null; + + // An impostor is reported as untrusted, not as "update Encly". + assertThat(EnclyReceiverCheck.check(inspector, TEST_PIN).status) + .isEqualTo(EnclyReceiverCheck.Status.UNTRUSTED); + } + + private static String hex(byte[] bytes) { + StringBuilder sb = new StringBuilder(); + for (byte b : bytes) sb.append(String.format(Locale.ROOT, "%02x", b)); + return sb.toString(); + } +} diff --git a/app/src/test/java/com/pasich/mynotes/utils/encly/HandoffPayloadBuilderTest.java b/app/src/test/java/com/pasich/mynotes/utils/encly/HandoffPayloadBuilderTest.java new file mode 100644 index 00000000..cd40a669 --- /dev/null +++ b/app/src/test/java/com/pasich/mynotes/utils/encly/HandoffPayloadBuilderTest.java @@ -0,0 +1,336 @@ +package com.pasich.mynotes.utils.encly; + +import static com.google.common.truth.Truth.assertThat; + +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import com.pasich.mynotes.data.model.Note; +import com.pasich.mynotes.data.model.Tag; +import com.pasich.mynotes.data.model.Task; +import com.pasich.mynotes.data.model.TaskCategory; +import com.pasich.mynotes.utils.managers.SystemTagsManager; +import java.io.ByteArrayInputStream; +import java.io.File; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.zip.ZipEntry; +import java.util.zip.ZipInputStream; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +/** Pins the wire shape of {@code handoff.json} that Encly parses (contract v1). */ +public class HandoffPayloadBuilderTest { + + private static final long EXPORTED_AT = 1_700_000_000_000L; + + @Rule public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + private final Map stable = new HashMap<>(); + private final HandoffPayloadBuilder.StableIds stableIds = + (type, localId) -> stable.get(type + ":" + localId); + + private JsonObject build( + List notes, List tags, List tasks, List categories) { + String json = + new HandoffPayloadBuilder(notes, tags, tasks, categories, stableIds, EXPORTED_AT) + .toJson(); + return JsonParser.parseString(json).getAsJsonObject(); + } + + private static Note note(int id, String title, String value) { + Note note = new Note().create(title, value, 1000L + id, ""); + note.setId(id); + return note; + } + + @Test + public void header() { + JsonObject root = build(List.of(), List.of(), List.of(), List.of()); + + assertThat(root.get("format").getAsString()).isEqualTo("mynotes-handoff"); + assertThat(root.get("schema").getAsInt()).isEqualTo(1); + assertThat(root.get("exportedAt").getAsLong()).isEqualTo(EXPORTED_AT); + assertThat(root.getAsJsonArray("tags")).isEmpty(); + assertThat(root.getAsJsonArray("notes")).isEmpty(); + assertThat(root.getAsJsonArray("taskCategories")).isEmpty(); + assertThat(root.getAsJsonArray("tasks")).isEmpty(); + } + + @Test + public void plainNote_hasNullValueJsonAndTagAndZeroAttachments() { + Note plain = note(3, "Shopping", "milk\nbread"); + + JsonObject n = + build(List.of(plain), List.of(), List.of(), List.of()) + .getAsJsonArray("notes") + .get(0) + .getAsJsonObject(); + + assertThat(n.get("id").getAsString()).isEqualTo("mynotes:note:3"); + assertThat(n.get("title").getAsString()).isEqualTo("Shopping"); + assertThat(n.get("value").getAsString()).isEqualTo("milk\nbread"); + assertThat(n.has("valueJson")).isTrue(); + assertThat(n.get("valueJson").isJsonNull()).isTrue(); + assertThat(n.get("date").getAsLong()).isEqualTo(1003L); + assertThat(n.get("tag").isJsonNull()).isTrue(); + assertThat(n.get("isTrash").getAsBoolean()).isFalse(); + assertThat(n.get("isPinned").getAsBoolean()).isFalse(); + assertThat(n.get("attachments").getAsInt()).isEqualTo(0); + } + + @Test + public void editorJsNote_sendsTheDocumentVerbatimAsAString() { + String doc = + "[{\"id\":\"a\",\"type\":\"paragraph\",\"data\":{\"text\":\"Hi & \\\"you\\\"\"}}]"; + Note rich = note(4, "Rich", "Hi & \"you\""); + rich.setValueJson(doc); + + JsonObject n = + build(List.of(rich), List.of(), List.of(), List.of()) + .getAsJsonArray("notes") + .get(0) + .getAsJsonObject(); + + assertThat(n.get("valueJson").isJsonPrimitive()).isTrue(); + assertThat(n.get("valueJson").getAsString()).isEqualTo(doc); + } + + @Test + public void trashedAndPinnedFlagsTravel() { + Note trashed = note(1, "old", "x"); + trashed.setTrash(true); + Note pinned = note(2, "top", "y"); + pinned.setPinned(true); + + JsonArray notes = + build(List.of(trashed, pinned), List.of(), List.of(), List.of()) + .getAsJsonArray("notes"); + + assertThat(notes.get(0).getAsJsonObject().get("isTrash").getAsBoolean()).isTrue(); + assertThat(notes.get(0).getAsJsonObject().get("isPinned").getAsBoolean()).isFalse(); + assertThat(notes.get(1).getAsJsonObject().get("isTrash").getAsBoolean()).isFalse(); + assertThat(notes.get(1).getAsJsonObject().get("isPinned").getAsBoolean()).isTrue(); + } + + @Test + public void attachmentsAreCountedNeverSent() { + Note withFiles = note(5, "trip", ""); + withFiles.setAttachments( + "[{\"url\":\"mynotes-attach://note_5/a.jpg\"},{\"url\":\"mynotes-attach://note_5/b.pdf\"}]"); + withFiles.setValueJson( + "[{\"type\":\"image\",\"data\":{\"file\":{\"url\":\"mynotes-attach://note_5/a.jpg\"}}}," + + "{\"type\":\"attaches\",\"data\":{\"file\":{\"url\":\"mynotes-attach://note_5/b.pdf\"}}}," + + "{\"type\":\"paragraph\",\"data\":{\"text\":\"t\"}}]"); + // A stale list must not hide references the document still has. + Note staleList = note(6, "stale", ""); + staleList.setAttachments("[]"); + staleList.setValueJson( + "[{\"type\":\"image\",\"data\":{\"file\":{\"url\":\"mynotes-attach://note_6/c.png\"}}}]"); + + JsonArray notes = + build(List.of(withFiles, staleList), List.of(), List.of(), List.of()) + .getAsJsonArray("notes"); + + assertThat(notes.get(0).getAsJsonObject().get("attachments").getAsInt()).isEqualTo(2); + assertThat(notes.get(1).getAsJsonObject().get("attachments").getAsInt()).isEqualTo(1); + // The note object carries exactly the contract fields; no file data, no reminder. + assertThat(notes.get(0).getAsJsonObject().keySet()) + .containsExactly( + "id", + "title", + "value", + "valueJson", + "date", + "tag", + "isTrash", + "isPinned", + "attachments"); + } + + @Test + public void unreadableAttachmentList_countsAsNone() { + Note broken = note(7, "b", ""); + broken.setAttachments("{not json"); + + assertThat(HandoffPayloadBuilder.attachmentCount(broken)).isEqualTo(0); + } + + @Test + public void noteTagIsSentByName_andOnlyUserTagsAreListed() { + Note tagged = note(8, "t", "v"); + tagged.setTag("Work"); + Tag work = new Tag().create("Work"); + work.id = 11; + work.setPosition(2); + Tag system = new Tag().create("allNotes", SystemTagsManager.SYSTEM_ACTION_ALL_NOTES); + system.id = 12; + + JsonObject root = build(List.of(tagged), List.of(work, system), List.of(), List.of()); + + assertThat(root.getAsJsonArray("notes").get(0).getAsJsonObject().get("tag").getAsString()) + .isEqualTo("Work"); + JsonArray tags = root.getAsJsonArray("tags"); + assertThat(tags).hasSize(1); + JsonObject t = tags.get(0).getAsJsonObject(); + assertThat(t.get("id").getAsString()).isEqualTo("mynotes:tag:11"); + assertThat(t.get("name").getAsString()).isEqualTo("Work"); + assertThat(t.get("position").getAsInt()).isEqualTo(2); + } + + @Test + public void stableIdComesFromSyncMetadata_elseFallback() { + stable.put("note:1", "3f6c1d1e-0000-4000-8000-000000000001"); + stable.put("tag:2", "3f6c1d1e-0000-4000-8000-000000000002"); + stable.put("task:3", "3f6c1d1e-0000-4000-8000-000000000003"); + stable.put("category:4", "3f6c1d1e-0000-4000-8000-000000000004"); + stable.put("note:9", " "); + Tag tag = new Tag().create("T"); + tag.id = 2; + Task task = new Task("Buy", 4); + task.setId(3); + TaskCategory category = new TaskCategory("Home", "#000000"); + category.setId(4); + + JsonObject root = + build( + List.of(note(1, "a", ""), note(9, "b", "")), + List.of(tag), + List.of(task), + List.of(category)); + + JsonArray notes = root.getAsJsonArray("notes"); + assertThat(notes.get(0).getAsJsonObject().get("id").getAsString()) + .isEqualTo("3f6c1d1e-0000-4000-8000-000000000001"); + // A blank stable id is no identity at all. + assertThat(notes.get(1).getAsJsonObject().get("id").getAsString()) + .isEqualTo("mynotes:note:9"); + assertThat(root.getAsJsonArray("tags").get(0).getAsJsonObject().get("id").getAsString()) + .isEqualTo("3f6c1d1e-0000-4000-8000-000000000002"); + JsonObject t = root.getAsJsonArray("tasks").get(0).getAsJsonObject(); + assertThat(t.get("id").getAsString()).isEqualTo("3f6c1d1e-0000-4000-8000-000000000003"); + assertThat(t.get("categoryId").getAsString()) + .isEqualTo("3f6c1d1e-0000-4000-8000-000000000004"); + assertThat( + root.getAsJsonArray("taskCategories") + .get(0) + .getAsJsonObject() + .get("id") + .getAsString()) + .isEqualTo("3f6c1d1e-0000-4000-8000-000000000004"); + } + + @Test + public void categoriesAndTasks() { + TaskCategory home = new TaskCategory("Home", "#112233"); + home.setId(4); + home.setPosition(1); + Task inHome = new Task("Fix sink", 4); + inHome.setId(20); + inHome.setDone(true); + inHome.setCreatedAt(5555L); + inHome.setPosition(3); + inHome.setDescription("call plumber"); + Task noCategory = new Task("Read", 0); + noCategory.setId(21); + Task danglingCategory = new Task("Ghost", 99); + danglingCategory.setId(22); + + JsonObject root = + build( + List.of(), + List.of(), + List.of(inHome, noCategory, danglingCategory), + List.of(home)); + + JsonObject c = root.getAsJsonArray("taskCategories").get(0).getAsJsonObject(); + assertThat(c.get("id").getAsString()).isEqualTo("mynotes:category:4"); + assertThat(c.get("name").getAsString()).isEqualTo("Home"); + assertThat(c.get("position").getAsInt()).isEqualTo(1); + assertThat(c.keySet()).containsExactly("id", "name", "position"); + + JsonArray tasks = root.getAsJsonArray("tasks"); + JsonObject first = tasks.get(0).getAsJsonObject(); + assertThat(first.get("id").getAsString()).isEqualTo("mynotes:task:20"); + assertThat(first.get("description").getAsString()).isEqualTo("Fix sink\ncall plumber"); + assertThat(first.get("isDone").getAsBoolean()).isTrue(); + assertThat(first.get("createdAt").getAsLong()).isEqualTo(5555L); + assertThat(first.get("categoryId").getAsString()).isEqualTo("mynotes:category:4"); + assertThat(first.get("position").getAsInt()).isEqualTo(3); + assertThat(first.keySet()) + .containsExactly( + "id", "description", "isDone", "createdAt", "categoryId", "position"); + + assertThat(tasks.get(1).getAsJsonObject().get("description").getAsString()) + .isEqualTo("Read"); + assertThat(tasks.get(1).getAsJsonObject().get("categoryId").isJsonNull()).isTrue(); + assertThat(tasks.get(2).getAsJsonObject().get("categoryId").isJsonNull()).isTrue(); + } + + @Test + public void summaryCountsWhatStaysBehind() { + Note a = note(1, "a", ""); + a.setAttachments("[{\"url\":\"x\"}]"); + a.setReminderTime(1L); + a.setPinned(true); + Note b = note(2, "b", ""); + Task task = new Task("t", 0); + task.setReminderTime(2L); + Tag user = new Tag().create("U"); + Tag system = new Tag().create("allNotes", SystemTagsManager.SYSTEM_ACTION_ALL_NOTES); + + HandoffPayloadBuilder.Summary summary = + HandoffPayloadBuilder.Summary.of( + List.of(a, b), List.of(user, system), List.of(task)); + + assertThat(summary.notes).isEqualTo(2); + assertThat(summary.tasks).isEqualTo(1); + assertThat(summary.tags).isEqualTo(1); + assertThat(summary.attachments).isEqualTo(1); + assertThat(summary.reminders).isEqualTo(2); + assertThat(summary.pinned).isEqualTo(1); + } + + @Test + public void archiveHoldsExactlyOneUtf8Entry_andClearRemovesIt() throws Exception { + File cache = temporaryFolder.newFolder("cache"); + Note unicode = note(1, "Нотатка ✓", "текст"); + List notes = new ArrayList<>(); + notes.add(unicode); + + File zip = + HandoffArchive.write( + cache, + new HandoffPayloadBuilder( + notes, List.of(), List.of(), List.of(), stableIds, EXPORTED_AT)); + + assertThat(zip.getParentFile()).isEqualTo(new File(cache, "encly-handoff")); + List names = new ArrayList<>(); + String body = null; + try (ZipInputStream in = + new ZipInputStream(new ByteArrayInputStream(Files.readAllBytes(zip.toPath())))) { + ZipEntry entry; + while ((entry = in.getNextEntry()) != null) { + names.add(entry.getName()); + body = new String(in.readAllBytes(), StandardCharsets.UTF_8); + } + } + assertThat(names).containsExactly("handoff.json"); + JsonObject root = JsonParser.parseString(body).getAsJsonObject(); + assertThat(root.getAsJsonArray("notes").get(0).getAsJsonObject().get("title").getAsString()) + .isEqualTo("Нотатка ✓"); + + assertThat(HandoffArchive.clear(cache)).isEqualTo(0); + assertThat(zip.exists()).isFalse(); + } + + @Test + public void clearWithoutDirectoryIsANoOp() throws Exception { + assertThat(HandoffArchive.clear(temporaryFolder.newFolder("empty"))).isEqualTo(0); + } +} diff --git a/app/src/test/java/com/pasich/mynotes/utils/encly/HandoffResultTest.java b/app/src/test/java/com/pasich/mynotes/utils/encly/HandoffResultTest.java new file mode 100644 index 00000000..561e287a --- /dev/null +++ b/app/src/test/java/com/pasich/mynotes/utils/encly/HandoffResultTest.java @@ -0,0 +1,79 @@ +package com.pasich.mynotes.utils.encly; + +import static com.google.common.truth.Truth.assertThat; + +import org.junit.Test; + +public class HandoffResultTest { + + private static final int RESULT_OK = -1; // android.app.Activity.RESULT_OK + private static final int RESULT_CANCELED = 0; // android.app.Activity.RESULT_CANCELED + + @Test + public void ok_withCounts() { + HandoffResult result = HandoffResult.parse(RESULT_OK, 12, 4, 3, 1, null); + + assertThat(result.success).isTrue(); + assertThat(result.notes).isEqualTo(12); + assertThat(result.tasks).isEqualTo(4); + assertThat(result.tags).isEqualTo(3); + assertThat(result.skipped).isEqualTo(1); + assertThat(result.reason).isNull(); + } + + @Test + public void ok_withMissingOrNegativeCounts_readsZero() { + HandoffResult result = HandoffResult.parse(RESULT_OK, null, null, -5, null, null); + + assertThat(result.success).isTrue(); + assertThat(result.notes).isEqualTo(0); + assertThat(result.tasks).isEqualTo(0); + assertThat(result.tags).isEqualTo(0); + assertThat(result.skipped).isEqualTo(0); + } + + @Test + public void canceled_withEveryKnownReason() { + assertThat(reason("untrusted_caller")).isEqualTo(HandoffResult.Reason.UNTRUSTED_CALLER); + assertThat(reason("cancelled")).isEqualTo(HandoffResult.Reason.CANCELLED); + assertThat(reason("unsupported_schema")).isEqualTo(HandoffResult.Reason.UNSUPPORTED_SCHEMA); + assertThat(reason("invalid_payload")).isEqualTo(HandoffResult.Reason.INVALID_PAYLOAD); + assertThat(reason("too_large")).isEqualTo(HandoffResult.Reason.TOO_LARGE); + assertThat(reason("failed")).isEqualTo(HandoffResult.Reason.FAILED); + } + + @Test + public void canceled_withoutReason_isACancellation() { + // Encly crashed, or the user backed out before it answered. + HandoffResult result = HandoffResult.parse(RESULT_CANCELED, null, null, null, null, null); + + assertThat(result.success).isFalse(); + assertThat(result.reason).isEqualTo(HandoffResult.Reason.CANCELLED); + assertThat(reason("")).isEqualTo(HandoffResult.Reason.CANCELLED); + } + + @Test + public void canceled_ignoresCountsItWasGiven() { + HandoffResult result = HandoffResult.parse(RESULT_CANCELED, 9, 9, 9, 9, "failed"); + + assertThat(result.success).isFalse(); + assertThat(result.notes).isEqualTo(0); + } + + @Test + public void unknownReason_isAFailure_notASilentCancel() { + assertThat(reason("disk_full")).isEqualTo(HandoffResult.Reason.FAILED); + } + + @Test + public void anyOtherResultCode_isNotASuccess() { + HandoffResult result = HandoffResult.parse(1, 5, 5, 5, 5, null); + + assertThat(result.success).isFalse(); + assertThat(result.reason).isEqualTo(HandoffResult.Reason.CANCELLED); + } + + private static HandoffResult.Reason reason(String wire) { + return HandoffResult.parse(RESULT_CANCELED, null, null, null, null, wire).reason; + } +} diff --git a/app/src/test/java/com/pasich/mynotes/utils/navigation/NoteNavigatorTest.java b/app/src/test/java/com/pasich/mynotes/utils/navigation/NoteNavigatorTest.java new file mode 100644 index 00000000..eefda03e --- /dev/null +++ b/app/src/test/java/com/pasich/mynotes/utils/navigation/NoteNavigatorTest.java @@ -0,0 +1,28 @@ +package com.pasich.mynotes.utils.navigation; + +import static com.google.common.truth.Truth.assertThat; + +import com.pasich.mynotes.ui.view.activity.noteEditor.NoteActivity; +import com.pasich.mynotes.ui.view.activity.noteEditor.NoteExtendedEditorActivity; +import org.junit.Test; + +public class NoteNavigatorTest { + + @Test + public void simpleEditor_forAPlainNote() { + assertThat(NoteNavigator.editorFor(false, false)).isEqualTo(NoteActivity.class); + } + + @Test + public void extendedEditor_whenSwitchedOn() { + assertThat(NoteNavigator.editorFor(true, false)) + .isEqualTo(NoteExtendedEditorActivity.class); + } + + @Test + public void extendedEditor_forANoteWithAttachments_evenWhenSwitchedOff() { + // A reminder used to open such notes in the simple editor, which cannot show them. + assertThat(NoteNavigator.editorFor(false, true)) + .isEqualTo(NoteExtendedEditorActivity.class); + } +} diff --git a/app/src/test/java/com/pasich/mynotes/utils/recycler/DragTargetTest.java b/app/src/test/java/com/pasich/mynotes/utils/recycler/DragTargetTest.java new file mode 100644 index 00000000..7de1808a --- /dev/null +++ b/app/src/test/java/com/pasich/mynotes/utils/recycler/DragTargetTest.java @@ -0,0 +1,52 @@ +package com.pasich.mynotes.utils.recycler; + +import static com.google.common.truth.Truth.assertThat; + +import com.pasich.mynotes.utils.recycler.DragTarget.Card; +import java.util.List; +import org.junit.Test; + +public class DragTargetTest { + + // A list: card 0 is short and at the very top, card 1 is tall. + private final Card first = new Card(0, 0, 0, 400, 100); + private final Card tall = new Card(1, 0, 100, 400, 400); + private final Card dragged = new Card(2, 0, 400, 400, 500); + + @Test + public void ontoTheFirstCard_onceItsMiddleIsPassed() { + // The dragged card's middle over the top half of the first card: no need to push the + // card above the top of the list. + assertThat(DragTarget.choose(new Card(1, 0, 100, 400, 200), 200, 40, List.of(first))) + .isEqualTo(first); + assertThat(DragTarget.choose(new Card(1, 0, 100, 400, 200), 200, 70, List.of(first))) + .isNull(); + } + + @Test + public void notBeforeTheMiddleHasPassedTheOtherCardsMiddle() { + assertThat(DragTarget.choose(dragged, 200, 300, List.of(first, tall))).isNull(); + assertThat(DragTarget.choose(dragged, 200, 240, List.of(first, tall))).isEqualTo(tall); + } + + @Test + public void afterTradingPlaces_theCardsDoNotTradeBack() { + // The short card was dragged down past the middle of the tall one, and they traded. + Card tallNow = new Card(0, 0, 0, 400, 300); + Card slotNow = new Card(1, 0, 300, 400, 400); + assertThat(DragTarget.choose(slotNow, 200, 260, List.of(tallNow))).isNull(); + } + + @Test + public void inTheGrid_sideways() { + Card left = new Card(0, 0, 0, 200, 300); + Card right = new Card(1, 200, 0, 400, 150); + assertThat(DragTarget.choose(left, 290, 100, List.of(right))).isNull(); + assertThat(DragTarget.choose(left, 310, 100, List.of(right))).isEqualTo(right); + } + + @Test + public void nothingUnderTheMiddle_staysPut() { + assertThat(DragTarget.choose(dragged, 200, 450, List.of(first, tall))).isNull(); + } +} diff --git a/app/src/test/java/com/pasich/mynotes/utils/recycler/NoteDragEdgeScrollTest.java b/app/src/test/java/com/pasich/mynotes/utils/recycler/NoteDragEdgeScrollTest.java new file mode 100644 index 00000000..1384520d --- /dev/null +++ b/app/src/test/java/com/pasich/mynotes/utils/recycler/NoteDragEdgeScrollTest.java @@ -0,0 +1,188 @@ +package com.pasich.mynotes.utils.recycler; + +import static com.google.common.truth.Truth.assertThat; +import static org.robolectric.Shadows.shadowOf; + +import android.app.Activity; +import android.graphics.Bitmap; +import android.graphics.Canvas; +import android.os.Looper; +import android.os.SystemClock; +import android.view.MotionEvent; +import android.view.View; +import android.view.ViewGroup; +import android.widget.FrameLayout; +import android.widget.TextView; +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.ItemTouchHelper; +import androidx.recyclerview.widget.RecyclerView; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.Robolectric; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; + +/** + * Holds a short card near the bottom of the visible list. As on the notes screen with the search + * bar expanded, the list is taller than what is visible, so the card never crosses the list's own + * bottom; it must scroll the list all the same. + */ +@RunWith(RobolectricTestRunner.class) +@Config(sdk = 35) +public class NoteDragEdgeScrollTest { + + private static final int VISIBLE = 600; + + private RecyclerView list; + private final List ids = new ArrayList<>(); + private ItemTouchHelper helper; + private boolean dragging; + private long downTime; + private final Canvas canvas = + new Canvas(Bitmap.createBitmap(400, 1000, Bitmap.Config.ARGB_8888)); + + private void setUp() { + Activity activity = Robolectric.buildActivity(Activity.class).setup().get(); + FrameLayout root = new FrameLayout(activity); + FrameLayout window = new FrameLayout(activity); + list = new RecyclerView(activity); + list.setLayoutManager(new NotesGridLayoutManager(1)); + for (int i = 0; i < 30; i++) ids.add(i); + list.setAdapter(new Cards()); + // The list reaches 300 px below what is visible, like under an expanded search bar. + window.addView(list, new FrameLayout.LayoutParams(400, VISIBLE + 300)); + root.addView(window, new FrameLayout.LayoutParams(400, VISIBLE)); + activity.setContentView(root); + NoteDragCallback callback = + new NoteDragCallback( + new NoteDragCallback.Host() { + @Override + public boolean canDrag() { + return true; + } + + @Override + public boolean canSwipe() { + return false; + } + + @Override + public boolean canTrade(int first, int second) { + return first >= 0 && second >= 0; + } + + @Override + public boolean isDragging() { + return dragging; + } + + @Override + public void move(int from, int to) { + ids.add(to, ids.remove(from)); + list.getAdapter().notifyItemMoved(from, to); + } + + @Override + public void onDragStarted(@NonNull RecyclerView.ViewHolder holder) { + dragging = true; + } + + @Override + public void onDragEnded(@NonNull RecyclerView.ViewHolder holder) { + dragging = false; + } + + @Override + public void onSwiped( + @NonNull RecyclerView.ViewHolder holder, int direction) {} + }); + helper = new ItemTouchHelper(callback); + callback.attachHelper(helper); + helper.attachToRecyclerView(list); + frames(200); + } + + /** Lets time pass, drawing the list every frame as the screen would. */ + private void frames(long ms) { + for (long t = 0; t < ms; t += 16) { + shadowOf(Looper.getMainLooper()).idleFor(Duration.ofMillis(16)); + list.draw(canvas); + } + } + + private void touch(int action, float x, float y) { + long now = SystemClock.uptimeMillis(); + if (action == MotionEvent.ACTION_DOWN) downTime = now; + MotionEvent event = MotionEvent.obtain(downTime, now, action, x, y, 0); + list.dispatchTouchEvent(event); + event.recycle(); + } + + @Test + public void aShortCardHeldAtTheVisibleBottom_scrollsTheListAndMovesDown() { + setUp(); + RecyclerView.ViewHolder holder = list.findViewHolderForAdapterPosition(1); + View card = holder.itemView; + float x = 200; + float y = card.getTop() + card.getHeight() / 2f; + touch(MotionEvent.ACTION_DOWN, x, y); + helper.startDrag(holder); + float to = VISIBLE - 20; + for (int i = 1; i <= 10; i++) { + touch(MotionEvent.ACTION_MOVE, x, y + (to - y) * i / 10); + frames(16); + } + int movedBeforeHold = ids.indexOf(1); + // Held still: only the edge scroll can move the list. + frames(800); + + assertThat(list.computeVerticalScrollOffset()).isGreaterThan(0); + assertThat(ids.indexOf(1)).isGreaterThan(movedBeforeHold); + touch(MotionEvent.ACTION_UP, x, to); + frames(400); + assertThat(dragging).isFalse(); + } + + @Test + public void aShortCardHeldInTheMiddle_doesNotScroll() { + setUp(); + RecyclerView.ViewHolder holder = list.findViewHolderForAdapterPosition(1); + View card = holder.itemView; + float y = card.getTop() + card.getHeight() / 2f; + touch(MotionEvent.ACTION_DOWN, 200, y); + helper.startDrag(holder); + for (int i = 1; i <= 10; i++) { + touch(MotionEvent.ACTION_MOVE, 200, y + 15 * i); + frames(16); + } + frames(500); + + assertThat(list.computeVerticalScrollOffset()).isEqualTo(0); + touch(MotionEvent.ACTION_UP, 200, y + 150); + frames(400); + } + + private final class Cards extends RecyclerView.Adapter { + @NonNull + @Override + public RecyclerView.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int type) { + TextView view = new TextView(parent.getContext()); + view.setLayoutParams( + new RecyclerView.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 80)); + return new RecyclerView.ViewHolder(view) {}; + } + + @Override + public void onBindViewHolder(@NonNull RecyclerView.ViewHolder holder, int position) { + ((TextView) holder.itemView).setText(String.valueOf(ids.get(position))); + } + + @Override + public int getItemCount() { + return ids.size(); + } + } +} diff --git a/app/src/test/java/com/pasich/mynotes/utils/recycler/NoteDragTest.java b/app/src/test/java/com/pasich/mynotes/utils/recycler/NoteDragTest.java new file mode 100644 index 00000000..a457c6e8 --- /dev/null +++ b/app/src/test/java/com/pasich/mynotes/utils/recycler/NoteDragTest.java @@ -0,0 +1,215 @@ +package com.pasich.mynotes.utils.recycler; + +import static com.google.common.truth.Truth.assertThat; +import static org.robolectric.Shadows.shadowOf; + +import android.app.Activity; +import android.os.Looper; +import android.os.SystemClock; +import android.view.MotionEvent; +import android.view.View; +import android.view.ViewGroup; +import android.widget.FrameLayout; +import android.widget.TextView; +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.ItemTouchHelper; +import androidx.recyclerview.widget.RecyclerView; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.Robolectric; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; + +/** + * Drags a card with real touch events through {@link ItemTouchHelper}, {@link NoteDragCallback} and + * {@link NotesGridLayoutManager}, in the list and in the grid, cards of different heights. + */ +@RunWith(RobolectricTestRunner.class) +@Config(sdk = 35) +public class NoteDragTest { + + private RecyclerView list; + private final List ids = new ArrayList<>(); + private ItemTouchHelper helper; + private boolean dragging; + private int moves; + private int ends; + private long downTime; + + private void setUp(int spans) { + Activity activity = Robolectric.buildActivity(Activity.class).setup().get(); + FrameLayout root = new FrameLayout(activity); + list = new RecyclerView(activity); + list.setLayoutManager(new NotesGridLayoutManager(spans)); + for (int i = 0; i < 20; i++) ids.add(i); + list.setAdapter(new Cards()); + root.addView(list, new FrameLayout.LayoutParams(400, 800)); + activity.setContentView(root); + helper = + new ItemTouchHelper( + new NoteDragCallback( + new NoteDragCallback.Host() { + @Override + public boolean canDrag() { + return true; + } + + @Override + public boolean canSwipe() { + return false; + } + + @Override + public boolean canTrade(int first, int second) { + return first >= 0 && second >= 0; + } + + @Override + public boolean isDragging() { + return dragging; + } + + @Override + public void move(int from, int to) { + ids.add(to, ids.remove(from)); + list.getAdapter().notifyItemMoved(from, to); + moves++; + } + + @Override + public void onDragStarted( + @NonNull RecyclerView.ViewHolder holder) { + dragging = true; + } + + @Override + public void onDragEnded( + @NonNull RecyclerView.ViewHolder holder) { + dragging = false; + ends++; + } + + @Override + public void onSwiped( + @NonNull RecyclerView.ViewHolder holder, + int direction) {} + }) { + @Override + public int interpolateOutOfBoundsScroll( + @NonNull RecyclerView recyclerView, + int viewSize, + int viewSizeOutOfBounds, + int totalSize, + long msSinceStartScroll) { + // No scrolling at the edges: under Robolectric's paused clock it + // never stops. + return 0; + } + }); + helper.attachToRecyclerView(list); + idle(200); + } + + private static void idle(long ms) { + shadowOf(Looper.getMainLooper()).idleFor(Duration.ofMillis(ms)); + } + + private void touch(int action, float x, float y) { + long now = SystemClock.uptimeMillis(); + if (action == MotionEvent.ACTION_DOWN) downTime = now; + MotionEvent event = MotionEvent.obtain(downTime, now, action, x, y, 0); + list.dispatchTouchEvent(event); + event.recycle(); + } + + /** Picks the card up by its middle and moves the finger to (toX, toY), then lets go. */ + private void drag(int position, float toX, float toY) { + RecyclerView.ViewHolder holder = list.findViewHolderForAdapterPosition(position); + View card = holder.itemView; + float x = card.getLeft() + card.getWidth() / 2f; + float y = card.getTop() + card.getHeight() / 2f; + touch(MotionEvent.ACTION_DOWN, x, y); + helper.startDrag(holder); + int steps = 12; + for (int i = 1; i <= steps; i++) { + touch(MotionEvent.ACTION_MOVE, x + (toX - x) * i / steps, y + (toY - y) * i / steps); + idle(20); + // Still held: the drag must not have been dropped on the way. + assertThat(ends).isEqualTo(0); + } + touch(MotionEvent.ACTION_UP, toX, toY); + idle(600); + } + + private View shown(int id) { + for (int i = 0; i < list.getChildCount(); i++) { + View child = list.getChildAt(i); + if (((TextView) child).getText().toString().equals(String.valueOf(id))) return child; + } + return null; + } + + private void assertFullyShown(int id) { + View card = shown(id); + assertThat(card).isNotNull(); + assertThat(card.getTop()).isAtLeast(0); + assertThat(card.getBottom()).isAtMost(list.getHeight()); + } + + @Test + public void inTheList_aCardDraggedOntoTheFirstOne_becomesFirstAndStaysOnScreen() { + setUp(1); + drag(3, 200, 30); + + assertThat(ids.subList(0, 4)).containsExactly(3, 0, 1, 2).inOrder(); + assertThat(ends).isEqualTo(1); + assertFullyShown(3); + assertThat(shown(3).getTop()).isEqualTo(0); + } + + @Test + public void inTheGrid_aCardDraggedToTheTop_becomesFirstAndStaysOnScreen() { + setUp(2); + drag(5, 100, 30); + + assertThat(ids.get(0)).isEqualTo(5); + assertThat(ends).isEqualTo(1); + assertFullyShown(5); + } + + @Test + public void inTheList_aCardDraggedDown_landsWhereItWasLetGo() { + setUp(1); + drag(0, 200, 470); + + assertThat(moves).isGreaterThan(0); + assertThat(ids.indexOf(0)).isGreaterThan(0); + assertFullyShown(0); + } + + private final class Cards extends RecyclerView.Adapter { + @NonNull + @Override + public RecyclerView.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int type) { + TextView view = new TextView(parent.getContext()); + view.setLayoutParams( + new RecyclerView.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 100)); + return new RecyclerView.ViewHolder(view) {}; + } + + @Override + public void onBindViewHolder(@NonNull RecyclerView.ViewHolder holder, int position) { + int id = ids.get(position); + holder.itemView.getLayoutParams().height = 100 + (id % 3) * 60; + ((TextView) holder.itemView).setText(String.valueOf(id)); + } + + @Override + public int getItemCount() { + return ids.size(); + } + } +} diff --git a/app/src/test/java/com/pasich/mynotes/utils/recycler/NoteListTransitionTest.java b/app/src/test/java/com/pasich/mynotes/utils/recycler/NoteListTransitionTest.java new file mode 100644 index 00000000..9c703222 --- /dev/null +++ b/app/src/test/java/com/pasich/mynotes/utils/recycler/NoteListTransitionTest.java @@ -0,0 +1,48 @@ +package com.pasich.mynotes.utils.recycler; + +import static com.google.common.truth.Truth.assertThat; + +import com.pasich.mynotes.data.model.Note; +import java.util.ArrayList; +import java.util.List; +import org.junit.Test; + +public class NoteListTransitionTest { + + private static List notes(int... ids) { + List list = new ArrayList<>(); + for (int id : ids) { + Note n = new Note().create("t" + id, "v" + id, id, ""); + n.setId(id); + list.add(n); + } + return list; + } + + @Test + public void sameOrder_noCrossfade() { + assertThat(NoteListTransition.needsCrossfade(notes(3, 2, 1), notes(3, 2, 1), 2)).isFalse(); + } + + @Test + public void reorder_crossfadesInAnyLayout() { + assertThat(NoteListTransition.needsCrossfade(notes(3, 2, 1), notes(1, 3, 2), 1)).isTrue(); + assertThat(NoteListTransition.needsCrossfade(notes(3, 2, 1), notes(1, 3, 2), 2)).isTrue(); + } + + @Test + public void insertOrRemove_crossfadesOnlyInGrid() { + assertThat(NoteListTransition.needsCrossfade(notes(3, 2, 1), notes(4, 3, 2, 1), 1)) + .isFalse(); + assertThat(NoteListTransition.needsCrossfade(notes(3, 2, 1), notes(3, 1), 1)).isFalse(); + assertThat(NoteListTransition.needsCrossfade(notes(3, 2, 1), notes(4, 3, 2, 1), 2)) + .isTrue(); + assertThat(NoteListTransition.needsCrossfade(notes(3, 2, 1), notes(3, 1), 2)).isTrue(); + } + + @Test + public void fromOrToEmpty_neverCrossfades() { + assertThat(NoteListTransition.needsCrossfade(notes(), notes(1, 2), 2)).isFalse(); + assertThat(NoteListTransition.needsCrossfade(notes(1, 2), notes(), 2)).isFalse(); + } +} diff --git a/app/src/test/java/com/pasich/mynotes/utils/recycler/NoteSwipeTest.java b/app/src/test/java/com/pasich/mynotes/utils/recycler/NoteSwipeTest.java new file mode 100644 index 00000000..68a6eb44 --- /dev/null +++ b/app/src/test/java/com/pasich/mynotes/utils/recycler/NoteSwipeTest.java @@ -0,0 +1,157 @@ +package com.pasich.mynotes.utils.recycler; + +import static com.google.common.truth.Truth.assertThat; +import static org.robolectric.Shadows.shadowOf; + +import android.app.Activity; +import android.graphics.Bitmap; +import android.graphics.Canvas; +import android.os.Looper; +import android.os.SystemClock; +import android.view.MotionEvent; +import android.view.ViewGroup; +import android.widget.FrameLayout; +import android.widget.TextView; +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.ItemTouchHelper; +import androidx.recyclerview.widget.RecyclerView; +import java.time.Duration; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.Robolectric; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; + +/** + * Swipes a card to the left with real touch events: it is selected, not removed, and must come back + * into its place although the list rebinds it in place (no change animations). + */ +@RunWith(RobolectricTestRunner.class) +@Config(sdk = 35) +public class NoteSwipeTest { + + private RecyclerView list; + private ItemTouchHelper helper; + private int swipedLeft; + private long downTime; + + private void setUp() { + Activity activity = Robolectric.buildActivity(Activity.class).setup().get(); + FrameLayout root = new FrameLayout(activity); + list = new RecyclerView(activity); + list.setLayoutManager(new NotesGridLayoutManager(1)); + list.setItemAnimator(new NotesItemAnimator(() -> true)); + list.setAdapter(new Cards()); + root.addView(list, new FrameLayout.LayoutParams(400, 800)); + activity.setContentView(root); + helper = + new ItemTouchHelper( + new NoteDragCallback( + new NoteDragCallback.Host() { + @Override + public boolean canDrag() { + return false; + } + + @Override + public boolean canSwipe() { + return true; + } + + @Override + public boolean canTrade(int first, int second) { + return false; + } + + @Override + public boolean isDragging() { + return false; + } + + @Override + public void move(int from, int to) {} + + @Override + public void onDragStarted( + @NonNull RecyclerView.ViewHolder holder) {} + + @Override + public void onDragEnded( + @NonNull RecyclerView.ViewHolder holder) {} + + @Override + public void onSwiped( + @NonNull RecyclerView.ViewHolder holder, + int direction) { + if (direction != ItemTouchHelper.LEFT) return; + swipedLeft++; + // What selecting does: the card is rebound in place. + list.getAdapter() + .notifyItemChanged( + holder.getBindingAdapterPosition(), + "selection"); + NoteDragCallback.returnSwipedCard(helper, holder); + } + })); + helper.attachToRecyclerView(list); + idle(200); + } + + /** Lets time pass, drawing the list every frame as the screen would. */ + private void idle(long ms) { + Canvas canvas = new Canvas(Bitmap.createBitmap(400, 800, Bitmap.Config.ARGB_8888)); + for (long t = 0; t < ms; t += 16) { + shadowOf(Looper.getMainLooper()).idleFor(Duration.ofMillis(16)); + list.draw(canvas); + } + } + + private void touch(int action, float x, float y) { + long now = SystemClock.uptimeMillis(); + if (action == MotionEvent.ACTION_DOWN) downTime = now; + MotionEvent event = MotionEvent.obtain(downTime, now, action, x, y, 0); + list.dispatchTouchEvent(event); + event.recycle(); + } + + @Test + public void aCardSwipedLeft_comesBackIntoPlace() { + setUp(); + RecyclerView.ViewHolder holder = list.findViewHolderForAdapterPosition(1); + float y = holder.itemView.getTop() + 50; + touch(MotionEvent.ACTION_DOWN, 380, y); + helper.startSwipe(holder); + for (int i = 1; i <= 10; i++) { + touch(MotionEvent.ACTION_MOVE, 380 - 36 * i, y); + idle(20); + } + touch(MotionEvent.ACTION_UP, 20, y); + idle(1000); + + assertThat(swipedLeft).isEqualTo(1); + RecyclerView.ViewHolder shown = list.findViewHolderForAdapterPosition(1); + assertThat(shown.itemView.getTranslationX()).isEqualTo(0f); + assertThat(shown.itemView.getAlpha()).isEqualTo(1f); + } + + private static final class Cards extends RecyclerView.Adapter { + @NonNull + @Override + public RecyclerView.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int type) { + TextView view = new TextView(parent.getContext()); + view.setLayoutParams( + new RecyclerView.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 100)); + return new RecyclerView.ViewHolder(view) {}; + } + + @Override + public void onBindViewHolder(@NonNull RecyclerView.ViewHolder holder, int position) { + ((TextView) holder.itemView).setText(String.valueOf(position)); + } + + @Override + public int getItemCount() { + return 10; + } + } +} diff --git a/app/src/test/java/com/pasich/mynotes/utils/reminder/ReminderDraftTest.java b/app/src/test/java/com/pasich/mynotes/utils/reminder/ReminderDraftTest.java new file mode 100644 index 00000000..5faa68f3 --- /dev/null +++ b/app/src/test/java/com/pasich/mynotes/utils/reminder/ReminderDraftTest.java @@ -0,0 +1,69 @@ +package com.pasich.mynotes.utils.reminder; + +import static com.google.common.truth.Truth.assertThat; + +import android.os.Bundle; +import com.pasich.mynotes.data.model.RepeatRule; +import java.util.TimeZone; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; + +@RunWith(RobolectricTestRunner.class) +@Config(sdk = 35) +public class ReminderDraftTest { + + @Test + public void everythingChosenSurvivesTheSavedState() { + ReminderDraft draft = new ReminderDraft(); + draft.time = 1_800_000_000_000L; + draft.repeatChipId = 42; + draft.customRule = RepeatRule.every(3, RepeatRule.Unit.HOURS, null); + draft.intervalMinutes = 15; + draft.pickedDateUtc = 1_799_971_200_000L; + + Bundle state = new Bundle(); + draft.save(state); + ReminderDraft restored = ReminderDraft.restore(state); + + assertThat(restored).isNotNull(); + assertThat(restored.time).isEqualTo(1_800_000_000_000L); + assertThat(restored.repeatChipId).isEqualTo(42); + assertThat(restored.customRule.getInterval()).isEqualTo(3); + assertThat(restored.customRule.getUnit()).isEqualTo(RepeatRule.Unit.HOURS); + assertThat(restored.intervalMinutes).isEqualTo(15); + assertThat(restored.pickedDateUtc).isEqualTo(1_799_971_200_000L); + } + + @Test + public void nothingChosenYet_restoresEmpty() { + Bundle state = new Bundle(); + new ReminderDraft().save(state); + ReminderDraft restored = ReminderDraft.restore(state); + + assertThat(restored.time).isNull(); + assertThat(restored.customRule).isNull(); + assertThat(restored.pickedDateUtc).isNull(); + } + + @Test + public void aFreshSheet_hasNoDraft() { + assertThat(ReminderDraft.restore(null)).isNull(); + assertThat(ReminderDraft.restore(new Bundle())).isNull(); + } + + @Test + public void aPickedDayIsThatDayInTheLocalZone_alsoWestOfUtc() { + // 2026-10-05 00:00 UTC, as the date picker gives it. + long picked = 1_791_158_400_000L; + TimeZone newYork = TimeZone.getTimeZone("America/New_York"); + long at = ReminderDraft.at(picked, 9, 30, newYork); + + java.util.Calendar local = java.util.Calendar.getInstance(newYork); + local.setTimeInMillis(at); + assertThat(local.get(java.util.Calendar.DAY_OF_MONTH)).isEqualTo(5); + assertThat(local.get(java.util.Calendar.HOUR_OF_DAY)).isEqualTo(9); + assertThat(local.get(java.util.Calendar.MINUTE)).isEqualTo(30); + } +} diff --git a/app/src/test/java/com/pasich/mynotes/utils/reminder/ReminderReschedulerTest.java b/app/src/test/java/com/pasich/mynotes/utils/reminder/ReminderReschedulerTest.java new file mode 100644 index 00000000..31a8d9f8 --- /dev/null +++ b/app/src/test/java/com/pasich/mynotes/utils/reminder/ReminderReschedulerTest.java @@ -0,0 +1,225 @@ +package com.pasich.mynotes.utils.reminder; + +import static com.google.common.truth.Truth.assertThat; +import static org.robolectric.Shadows.shadowOf; + +import android.app.AlarmManager; +import android.content.Context; +import android.content.Intent; +import androidx.room.Room; +import com.pasich.mynotes.data.database.AppDatabase; +import com.pasich.mynotes.data.model.Note; +import com.pasich.mynotes.data.model.RepeatRule; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.RuntimeEnvironment; +import org.robolectric.annotation.Config; +import org.robolectric.shadows.ShadowAlarmManager; + +@RunWith(RobolectricTestRunner.class) +@Config(sdk = 35) +public class ReminderReschedulerTest { + + private static final long HOUR = 3_600_000L; + private static final long NOW = 1_780_000_000_000L; + + private Context context; + private AppDatabase db; + private ReminderRescheduler rescheduler; + private ShadowAlarmManager alarms; + + @Before + public void setUp() { + context = RuntimeEnvironment.getApplication(); + db = + Room.inMemoryDatabaseBuilder(context, AppDatabase.class) + .allowMainThreadQueries() + .build(); + rescheduler = new ReminderRescheduler(context, db); + alarms = shadowOf((AlarmManager) context.getSystemService(Context.ALARM_SERVICE)); + ShadowAlarmManager.setCanScheduleExactAlarms(true); + } + + @After + public void tearDown() { + db.close(); + ShadowAlarmManager.reset(); + context.getSharedPreferences("reminder_snoozes", Context.MODE_PRIVATE) + .edit() + .clear() + .commit(); + } + + private int note(Long time, String repeat, boolean trash) { + Note note = new Note().create("Title", "Body", NOW); + note.setReminderTime(time); + note.setReminderRepeat(repeat); + note.setTrash(trash); + return db.noteDao().addNote(note).intValue(); + } + + private List alarmsFor(int noteId, String action) { + List found = new ArrayList<>(); + for (ShadowAlarmManager.ScheduledAlarm alarm : alarms.getScheduledAlarms()) { + Intent intent = shadowOf(alarm.operation).getSavedIntent(); + if (intent.getIntExtra(ReminderManager.EXTRA_NOTE_ID, -1) != noteId) continue; + String a = intent.getAction(); + if (action == null ? a == null : action.equals(a)) found.add(alarm); + } + return found; + } + + @Test + public void rescheduleAll_armsUpcomingReminders() { + int id = note(NOW + 2 * HOUR, "NONE", false); + + ReminderRescheduler.Result result = rescheduler.rescheduleAll(NOW); + + assertThat(alarmsFor(id, null)).hasSize(1); + assertThat(alarmsFor(id, null).get(0).getTriggerAtMs()).isEqualTo(NOW + 2 * HOUR); + assertThat(alarmsFor(id, null).get(0).getWindowLengthMs()) + .isEqualTo(ShadowAlarmManager.WINDOW_EXACT); + assertThat(result.armed).isEqualTo(1); + assertThat(result.inexact).isEqualTo(0); + } + + @Test + public void rescheduleAll_armsAMissedRepeatingReminderForNow() { + // Missed while the phone was off: armed at its own, past, time so it fires at once and + // the receiver posts it and rolls it forward from the anchor. + long missed = NOW - 3 * 24 * HOUR; + int daily = note(missed, "DAILY", false); + int custom = + note(missed, RepeatRule.every(3, RepeatRule.Unit.HOURS, missed).serialize(), false); + + rescheduler.rescheduleAll(NOW); + + assertThat(alarmsFor(daily, null)).hasSize(1); + assertThat(alarmsFor(daily, null).get(0).getTriggerAtMs()).isEqualTo(missed); + assertThat(alarmsFor(custom, null)).hasSize(1); + } + + @Test + public void rescheduleAll_oneTimeReminders_onlyRecentlyMissedOnesAreCaughtUp() { + int recent = note(NOW - HOUR, "NONE", false); + int stale = note(NOW - 3 * 24 * HOUR, "NONE", false); + + rescheduler.rescheduleAll(NOW); + + assertThat(alarmsFor(recent, null)).hasSize(1); + assertThat(alarmsFor(stale, null)).isEmpty(); + } + + @Test + public void rescheduleAll_skipsTrashAndNotesWithoutReminder() { + int trashed = note(NOW + HOUR, "DAILY", true); + int none = note(null, "NONE", false); + + rescheduler.rescheduleAll(NOW); + + assertThat(alarmsFor(trashed, null)).isEmpty(); + assertThat(alarmsFor(none, null)).isEmpty(); + } + + @Test + public void rescheduleAll_restoresSnoozes_andForgetsThoseOfDeletedNotes() { + int id = note(NOW + 5 * HOUR, "DAILY", false); + SnoozeStore snoozes = new SnoozeStore(context); + snoozes.put(id, NOW + HOUR, 10); + snoozes.put(9_999, NOW + HOUR, 0); + + rescheduler.rescheduleAll(NOW); + + List snooze = + alarmsFor(id, ReminderManager.ACTION_SNOOZE); + assertThat(snooze).hasSize(1); + assertThat(snooze.get(0).getTriggerAtMs()).isEqualTo(NOW + HOUR); + // The snooze has its own alarm; the schedule is still armed beside it. + assertThat(alarmsFor(id, null)).hasSize(1); + assertThat(snoozes.get(9_999)).isNull(); + assertThat(snoozes.get(id)).isNotNull(); + } + + @Test + public void rescheduleAll_withoutExactAlarms_armsInexactlyAndSaysSo() { + ShadowAlarmManager.setCanScheduleExactAlarms(false); + int id = note(NOW + HOUR, "NONE", false); + + ReminderRescheduler.Result result = rescheduler.rescheduleAll(NOW); + + assertThat(alarmsFor(id, null)).hasSize(1); + assertThat(alarmsFor(id, null).get(0).getWindowLengthMs()) + .isNotEqualTo(ShadowAlarmManager.WINDOW_EXACT); + assertThat(result.inexact).isEqualTo(1); + } + + @Test + public void rescheduleAll_isIdempotent() { + int id = note(NOW + HOUR, "WEEKLY", false); + + rescheduler.rescheduleAll(NOW); + rescheduler.rescheduleAll(NOW); + + assertThat(alarmsFor(id, null)).hasSize(1); + } + + @Test + public void nagAndSnooze_doNotReplaceTheSchedule() { + int id = note(NOW + 24 * HOUR, "DAILY", false); + rescheduler.rescheduleAll(NOW); + + ReminderManager.scheduleNag(context, id, 10, NOW); + ReminderManager.scheduleSnooze(context, id, NOW + HOUR, 0); + + assertThat(alarmsFor(id, null)).hasSize(1); + assertThat(alarmsFor(id, null).get(0).getTriggerAtMs()).isEqualTo(NOW + 24 * HOUR); + assertThat(alarmsFor(id, ReminderManager.ACTION_NAG)).hasSize(1); + assertThat(alarmsFor(id, ReminderManager.ACTION_NAG).get(0).getTriggerAtMs()) + .isEqualTo(NOW + 10 * 60_000L); + assertThat(alarmsFor(id, ReminderManager.ACTION_SNOOZE)).hasSize(1); + + ReminderManager.cancelNag(context, id); + assertThat(alarmsFor(id, ReminderManager.ACTION_NAG)).isEmpty(); + assertThat(alarmsFor(id, null)).hasSize(1); + } + + @Test + public void reconcileNotes_followsWhatSyncWrote() { + int moved = note(NOW + HOUR, "DAILY", false); + int cleared = note(NOW + HOUR, "NONE", false); + int deleted = note(NOW + HOUR, "NONE", false); + rescheduler.rescheduleAll(NOW); + ReminderManager.scheduleSnooze(context, cleared, NOW + 2 * HOUR, 0); + ReminderManager.scheduleSnooze(context, deleted, NOW + 2 * HOUR, 0); + + // Another device moved one reminder, turned one off and deleted a note. + db.noteDao().updateReminderFullSync(moved, NOW + 5 * HOUR, "DAILY", 0); + db.noteDao().clearReminderSync(cleared); + db.noteDao().deleteById(deleted); + rescheduler.reconcileNotes(Arrays.asList(moved, cleared, deleted), NOW); + + assertThat(alarmsFor(moved, null)).hasSize(1); + assertThat(alarmsFor(moved, null).get(0).getTriggerAtMs()).isEqualTo(NOW + 5 * HOUR); + assertThat(alarmsFor(cleared, null)).isEmpty(); + // A snooze of a notification already shown is kept until the note itself goes. + assertThat(alarmsFor(cleared, ReminderManager.ACTION_SNOOZE)).hasSize(1); + assertThat(alarmsFor(deleted, null)).isEmpty(); + assertThat(alarmsFor(deleted, ReminderManager.ACTION_SNOOZE)).isEmpty(); + assertThat(new SnoozeStore(context).get(deleted)).isNull(); + } + + @Test + public void countUpcoming_countsOnlyFutureReminders() { + note(NOW + HOUR, "NONE", false); + note(NOW - HOUR, "DAILY", false); + note(NOW + HOUR, "NONE", true); + + assertThat(rescheduler.countUpcoming(NOW)).isEqualTo(1); + } +} diff --git a/app/src/test/java/com/pasich/mynotes/utils/search/SearchFilterTest.java b/app/src/test/java/com/pasich/mynotes/utils/search/SearchFilterTest.java new file mode 100644 index 00000000..4519f895 --- /dev/null +++ b/app/src/test/java/com/pasich/mynotes/utils/search/SearchFilterTest.java @@ -0,0 +1,264 @@ +package com.pasich.mynotes.utils.search; + +import static com.google.common.truth.Truth.assertThat; + +import com.pasich.mynotes.data.model.Note; +import com.pasich.mynotes.utils.search.SearchHit.MatchKind; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import org.junit.Test; + +/** Tests the production {@link NoteSearchRanker} and {@link SearchText} used by main search. */ +public class SearchFilterTest { + + private final NoteSearchRanker ranker = new NoteSearchRanker(); + private int nextId = 1; + + private Note note(String title, String body, long date) { + return note(title, body, date, ""); + } + + private Note note(String title, String body, long date, String tag) { + Note n = new Note().create(title, body, date, tag); + n.setId(nextId++); + return n; + } + + private List titles(List notes, String query) { + return titles(notes, query, ""); + } + + private List titles(List notes, String query, String tagFilter) { + List out = new ArrayList<>(); + for (SearchHit hit : ranker.rank(notes, query, tagFilter)) out.add(hit.note().getTitle()); + return out; + } + + // ---- Ranking buckets (#175) --------------------------------------------------------------- + + @Test + public void exactTitle_outranksNewerBodyMatches() { + List notes = + Arrays.asList( + note("Weekly", "review the PR before lunch", 300), + note("Standup", "two PRs left", 200), + note("PR", "", 100)); + assertThat(titles(notes, "PR")).containsExactly("PR", "Weekly", "Standup").inOrder(); + } + + @Test + public void allBuckets_inIssueOrder() { + List notes = + Arrays.asList( + note("Notes", "a plan for the week", 600), + note("Groceries", "", 500, "plan"), + note("Airplane", "", 400), + note("My plan for May", "", 300), + note("Planning", "", 200), + note("Plan", "", 100)); + List hits = ranker.rank(notes, "plan", ""); + + List kinds = new ArrayList<>(); + for (SearchHit h : hits) kinds.add(h.kind()); + assertThat(kinds) + .containsExactly( + MatchKind.EXACT_TITLE, + MatchKind.TITLE_PREFIX, + MatchKind.TITLE_WORD, + MatchKind.TITLE_CONTAINS, + MatchKind.TAG, + MatchKind.BODY) + .inOrder(); + assertThat(titles(notes, "plan")) + .containsExactly( + "Plan", "Planning", "My plan for May", "Airplane", "Groceries", "Notes") + .inOrder(); + } + + @Test + public void wholeWord_needsBoundariesOnBothSides() { + List notes = + Arrays.asList(note("Code review", "", 300), note("Ask a PR-review", "", 200)); + List hits = ranker.rank(notes, "review", ""); + assertThat(hits.get(0).kind()).isEqualTo(MatchKind.TITLE_WORD); + assertThat(hits.get(1).kind()).isEqualTo(MatchKind.TITLE_WORD); + + List partial = + ranker.rank(Collections.singletonList(note("Reviews", "", 1)), "view", ""); + assertThat(partial.get(0).kind()).isEqualTo(MatchKind.TITLE_CONTAINS); + } + + @Test + public void laterWholeWordOccurrence_stillCounts() { + // The first "art" is inside "Start", the second stands alone. + List hits = + ranker.rank(Collections.singletonList(note("Start art club", "", 1)), "art", ""); + assertThat(hits.get(0).kind()).isEqualTo(MatchKind.TITLE_WORD); + } + + // ---- Normalization ------------------------------------------------------------------------ + + @Test + public void caseAndDiacritics_areIgnored() { + List notes = + Arrays.asList( + note("Café menu", "", 300), + note("CAFE", "", 200), + note("Other", "naïve plan", 100)); + assertThat(titles(notes, "cafe")).containsExactly("CAFE", "Café menu").inOrder(); + assertThat(titles(notes, "CAFÉ")).containsExactly("CAFE", "Café menu").inOrder(); + assertThat(titles(notes, "naive")).containsExactly("Other"); + } + + @Test + public void cyrillic_matchesCaseInsensitively() { + List notes = Arrays.asList(note("Список", "", 200), note("Інше", "список", 100)); + // "СПИСОК" finds the title exactly and the other note through its text. + assertThat(titles(notes, "СПИСОК")).containsExactly("Список", "Інше").inOrder(); + } + + @Test + public void trailingAndInnerSpaces_areCollapsed() { + List notes = + Arrays.asList(note("Shopping list", "", 200), note("Shopping", "", 100)); + List hits = ranker.rank(notes, " shopping list ", ""); + assertThat(hits).hasSize(1); + assertThat(hits.get(0).kind()).isEqualTo(MatchKind.EXACT_TITLE); + + // A trailing space used to defeat the exact-title check. + assertThat(ranker.rank(notes, "shopping ", "").get(0).kind()) + .isEqualTo(MatchKind.EXACT_TITLE); + } + + @Test + public void normalize_foldsCompatibilityForms() { + assertThat(SearchText.normalize(" file \tNOTES ")).isEqualTo("file notes"); + assertThat(SearchText.normalize("Ångström")).isEqualTo("angstrom"); + assertThat(SearchText.normalize(null)).isEmpty(); + } + + @Test + public void locate_returnsRangeInOriginalText() { + // "é" here is e + a combining accent: two chars that fold to one. + String title = "Le café crème"; + int[] range = SearchText.locate(title, SearchText.normalize("café creme")); + assertThat(range).isNotNull(); + assertThat(title.substring(range[0], range[1])).isEqualTo("café crème"); + + assertThat(SearchText.locate("Groceries", "plan")).isNull(); + } + + // ---- Short queries ------------------------------------------------------------------------ + + @Test + public void emptyOrBlankQuery_matchesNothing() { + List notes = Arrays.asList(note("A", "a", 1), note("B", "b", 2)); + assertThat(ranker.rank(notes, "", "")).isEmpty(); + assertThat(ranker.rank(notes, " ", "")).isEmpty(); + assertThat(ranker.rank(notes, null, "")).isEmpty(); + assertThat(NoteSearchRanker.searchesTitlesOnly(" ")).isTrue(); + } + + @Test + public void oneCharacter_searchesTitlesOnly() { + List notes = + Arrays.asList( + note("Notes", "x marks the spot", 300, "x"), + note("Box", "", 200), + note("X", "", 100)); + assertThat(titles(notes, "x")).containsExactly("X", "Box").inOrder(); + assertThat(NoteSearchRanker.searchesTitlesOnly(" x ")).isTrue(); + assertThat(NoteSearchRanker.searchesTitlesOnly("xm")).isFalse(); + } + + @Test + public void twoCharacters_alsoSearchTagsAndText() { + List notes = + Arrays.asList(note("Notes", "x marks the spot", 300), note("Box", "", 200)); + assertThat(titles(notes, "x ma")).containsExactly("Notes"); + assertThat(titles(notes, "ox")).containsExactly("Box"); + } + + // ---- Ties and filters --------------------------------------------------------------------- + + @Test + public void sameBucket_pinnedFirstThenNewest() { + Note oldPinned = note("Trip budget", "", 100); + oldPinned.setPinned(true); + List notes = + Arrays.asList(note("Trip plan", "", 200), oldPinned, note("Trip photos", "", 300)); + assertThat(titles(notes, "trip")) + .containsExactly("Trip budget", "Trip photos", "Trip plan") + .inOrder(); + } + + @Test + public void pinning_neverBeatsABetterBucket() { + Note pinnedBody = note("Ideas", "PR checklist", 999); + pinnedBody.setPinned(true); + List notes = Arrays.asList(pinnedBody, note("PR", "", 1)); + assertThat(titles(notes, "pr")).containsExactly("PR", "Ideas").inOrder(); + } + + @Test + public void tagFilter_keepsOnlyThatTag() { + List notes = + Arrays.asList( + note("Work plan", "", 200, "work"), note("Home plan", "", 100, "home")); + assertThat(titles(notes, "plan", "home")).containsExactly("Home plan"); + assertThat(titles(notes, "plan", null)).containsExactly("Work plan", "Home plan").inOrder(); + } + + @Test + public void noMatch_returnsEmpty() { + List notes = Arrays.asList(note("Alpha", "beta", 1), note("Gamma", "delta", 2)); + assertThat(ranker.rank(notes, "zzznomatch", "")).isEmpty(); + } + + @Test + public void hitsCarryTheFoldedQuery() { + List hits = + ranker.rank(Collections.singletonList(note("Café", "", 1)), " CAFÉ ", ""); + assertThat(hits.get(0).query()).isEqualTo("cafe"); + } + + @Test + public void newListInstance_isReindexed() { + Note before = note("Draft", "", 1); + assertThat(titles(Collections.singletonList(before), "draft")).hasSize(1); + + Note renamed = note("Final", "", 1); + renamed.setId(before.getId()); + List afterSync = new ArrayList<>(Collections.singletonList(renamed)); + assertThat(titles(afterSync, "draft")).isEmpty(); + assertThat(titles(afterSync, "final")).containsExactly("Final"); + } + + // ---- Speed -------------------------------------------------------------------------------- + + @Test + public void fiveThousandNotes_rankQuickly() { + List notes = new ArrayList<>(); + String body = "Зустріч з командою, " + "café notes and a long paragraph of ordinary text. "; + StringBuilder longBody = new StringBuilder(); + for (int i = 0; i < 20; i++) longBody.append(body); + for (int i = 0; i < 5000; i++) { + notes.add(note("Note " + i + " meeting", longBody.toString(), i)); + } + notes.add(note("PR", "", 0)); + + long start = System.nanoTime(); + List first = ranker.rank(notes, "pr", ""); + for (String q : new String[] {"p", "pr", "mee", "meeting", "café", "zz"}) { + ranker.rank(notes, q, ""); + } + long elapsedMs = (System.nanoTime() - start) / 1_000_000; + + assertThat(first.get(0).note().getTitle()).isEqualTo("PR"); + // A smoke bound, generous enough for a loaded CI machine: indexing 5k notes once plus + // seven queries over them. + assertThat(elapsedMs).isLessThan(3000L); + } +} diff --git a/app/src/test/java/com/pasich/mynotes/utils/search/SearchHintFitterTest.java b/app/src/test/java/com/pasich/mynotes/utils/search/SearchHintFitterTest.java new file mode 100644 index 00000000..c211a122 --- /dev/null +++ b/app/src/test/java/com/pasich/mynotes/utils/search/SearchHintFitterTest.java @@ -0,0 +1,26 @@ +package com.pasich.mynotes.utils.search; + +import static com.google.common.truth.Truth.assertThat; + +import org.junit.Test; + +public class SearchHintFitterTest { + + @Test + public void fullHintThatFits_isKept() { + assertThat(SearchHintFitter.choose(180f, 200f, "In Notizen suchen", "Suchen")) + .isEqualTo("In Notizen suchen"); + } + + @Test + public void fullHintTooWide_fallsBackToShortHint() { + assertThat(SearchHintFitter.choose(320f, 200f, "In Notizen suchen", "Suchen")) + .isEqualTo("Suchen"); + } + + @Test + public void subPixelRounding_doesNotDropTheFullHint() { + assertThat(SearchHintFitter.choose(200.3f, 200f, "Search notes", "Search")) + .isEqualTo("Search notes"); + } +} diff --git a/app/src/test/java/com/pasich/mynotes/utils/tool/FormatListToolTest.java b/app/src/test/java/com/pasich/mynotes/utils/tool/FormatListToolTest.java new file mode 100644 index 00000000..d9596a14 --- /dev/null +++ b/app/src/test/java/com/pasich/mynotes/utils/tool/FormatListToolTest.java @@ -0,0 +1,81 @@ +package com.pasich.mynotes.utils.tool; + +import static com.google.common.truth.Truth.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import android.view.MenuItem; +import com.pasich.mynotes.R; +import com.pasich.mynotes.cache.AppPreferencesCache; +import org.junit.Before; +import org.junit.Test; + +public class FormatListToolTest { + + private AppPreferencesCache cache; + private MenuItem item; + + @Before + public void setUp() { + cache = mock(AppPreferencesCache.class); + item = mock(MenuItem.class); + } + + @Test + public void init_showsTheSavedListLayout() { + when(cache.getFormatPref()).thenReturn(FormatListTool.FORMAT_LIST); + + new FormatListTool(cache).init(item); + + verify(item).setIcon(R.drawable.ic_edit_format_list); + } + + @Test + public void init_showsTheSavedGridLayout() { + when(cache.getFormatPref()).thenReturn(FormatListTool.FORMAT_GRID); + + new FormatListTool(cache).init(item); + + verify(item).setIcon(R.drawable.ic_edit_format_tiles); + } + + @Test + public void unknownSavedValue_fallsBackToList() { + when(cache.getFormatPref()).thenReturn(0); + + FormatListTool tool = new FormatListTool(cache); + + assertThat(tool.getFormat()).isEqualTo(FormatListTool.FORMAT_LIST); + } + + @Test + public void setFormat_savesTheNewLayoutAndUpdatesTheIcon() { + when(cache.getFormatPref()).thenReturn(FormatListTool.FORMAT_LIST); + + boolean changed = new FormatListTool(cache).setFormat(FormatListTool.FORMAT_GRID, item); + + assertThat(changed).isTrue(); + verify(cache).setFormatPref(FormatListTool.FORMAT_GRID); + verify(item).setIcon(R.drawable.ic_edit_format_tiles); + } + + @Test + public void setFormat_sameLayout_savesNothing() { + when(cache.getFormatPref()).thenReturn(FormatListTool.FORMAT_GRID); + + boolean changed = new FormatListTool(cache).setFormat(FormatListTool.FORMAT_GRID, item); + + assertThat(changed).isFalse(); + verify(cache, never()).setFormatPref(FormatListTool.FORMAT_GRID); + } + + @Test + public void contentDescription_namesTheCurrentLayout() { + assertThat(FormatListTool.contentDescriptionFor(FormatListTool.FORMAT_LIST)) + .isEqualTo(R.string.view_options_cd_list); + assertThat(FormatListTool.contentDescriptionFor(FormatListTool.FORMAT_GRID)) + .isEqualTo(R.string.view_options_cd_grid); + } +} diff --git a/doc/scr1.jpg b/doc/scr1.jpg index 4bbedf12..a0a939b2 100644 Binary files a/doc/scr1.jpg and b/doc/scr1.jpg differ diff --git a/doc/scr2.jpg b/doc/scr2.jpg index 7c1aae4c..ab66c1ad 100644 Binary files a/doc/scr2.jpg and b/doc/scr2.jpg differ diff --git a/doc/scr3.jpg b/doc/scr3.jpg new file mode 100644 index 00000000..bb65c492 Binary files /dev/null and b/doc/scr3.jpg differ diff --git a/doc/scr4.jpg b/doc/scr4.jpg new file mode 100644 index 00000000..e24e468f Binary files /dev/null and b/doc/scr4.jpg differ diff --git a/notes_editor/package.json b/notes_editor/package.json index 89ecefb7..1c0ccf30 100644 --- a/notes_editor/package.json +++ b/notes_editor/package.json @@ -5,7 +5,8 @@ "type": "module", "scripts": { "build": "rollup -c && node copy-dist.js", - "dev": "rollup -c -w" + "dev": "rollup -c -w", + "test": "node --test test/*.test.mjs" }, "keywords": [], "author": "pasichDev", diff --git a/notes_editor/rollup.config.js b/notes_editor/rollup.config.js index 38ea11c3..be711710 100644 --- a/notes_editor/rollup.config.js +++ b/notes_editor/rollup.config.js @@ -79,6 +79,9 @@ export default [ 'deleteAttachmentBlockFromAndroid', 'toggleReadModeFromAndroid', 'saveContent', + 'flushContent', + 'restoreViewState', + 'setReadMode', 'uploadAttachment', 'loadNote', 'setThemeColors' diff --git a/notes_editor/src/custom/locales.js b/notes_editor/src/custom/locales.js index ac954329..d0056138 100644 --- a/notes_editor/src/custom/locales.js +++ b/notes_editor/src/custom/locales.js @@ -110,6 +110,169 @@ window.EditorLocales = { } } }, + + be: { + placeholder: 'Пачніце пісаць...', + title_placeholder: 'Назва', + messages: { + ui: { + blockTunes: { + toggler: { + 'Click to tune': 'Націсніце, каб наладзіць', + 'or drag to move': 'або перацягніце' + } + }, + inlineToolbar: { converter: { 'Convert to': 'Пераўтварыць у' } }, + toolbar: { toolbox: { Add: 'Дадаць' } }, + popover: { + Filter: 'Пошук', + 'Nothing found': 'Нічога не знойдзена', + 'Convert to': 'Пераўтварыць у' + } + }, + toolNames: { + Text: 'Абзац', + Heading: 'Загаловак', + 'Ordered List': 'Нумараваны спіс', + 'Unordered List': 'Маркіраваны спіс', + Warning: 'Папярэджанне', + Checklist: 'Спіс задач', + Quote: 'Цытата', + Code: 'Код', + Delimiter: 'Раздзяляльнік', + 'Raw HTML': 'HTML-фрагмент', + Table: 'Табліца', + Link: 'Спасылка', + Marker: 'Маркер', + Bold: 'Тоўсты', + Italic: 'Курсіў', + InlineCode: 'Монашырынны', + Image: 'Выява' + }, + tools: { + paragraph: { 'Enter something': 'Увядзіце тэкст' }, + link: { 'Add a link': 'Дадайце спасылку' }, + header: { 'Heading 1': 'Загаловак 1', 'Heading 2': 'Загаловак 2' } + }, + blockTunes: { + delete: { + Delete: 'Выдаліць', + 'Click to delete': 'Пацвердзіць выдаленне' + }, + moveUp: { 'Move up': 'Уверх' }, + moveDown: { 'Move down': 'Уніз' } + } + } + }, + + it: { + placeholder: 'Inizia a scrivere...', + title_placeholder: 'Titolo', + messages: { + ui: { + blockTunes: { + toggler: { + 'Click to tune': 'Tocca per modificare', + 'or drag to move': 'o trascina per spostare' + } + }, + inlineToolbar: { converter: { 'Convert to': 'Converti in' } }, + toolbar: { toolbox: { Add: 'Aggiungi' } }, + popover: { + Filter: 'Cerca', + 'Nothing found': 'Nessun risultato', + 'Convert to': 'Converti in' + } + }, + toolNames: { + Text: 'Paragrafo', + Heading: 'Intestazione', + 'Ordered List': 'Elenco numerato', + 'Unordered List': 'Elenco puntato', + Warning: 'Avviso', + Checklist: 'Lista di controllo', + Quote: 'Citazione', + Code: 'Codice', + Delimiter: 'Separatore', + 'Raw HTML': 'HTML', + Table: 'Tabella', + Link: 'Link', + Marker: 'Evidenziatore', + Bold: 'Grassetto', + Italic: 'Corsivo', + InlineCode: 'Monospaziato', + Image: 'Immagine' + }, + tools: { + paragraph: { 'Enter something': 'Inserisci il testo' }, + link: { 'Add a link': 'Aggiungi un link' }, + header: { 'Heading 1': 'Intestazione 1', 'Heading 2': 'Intestazione 2' } + }, + blockTunes: { + delete: { + Delete: 'Elimina', + 'Click to delete': 'Conferma eliminazione' + }, + moveUp: { 'Move up': 'Sposta su' }, + moveDown: { 'Move down': 'Sposta giù' } + } + } + }, + + kk: { + placeholder: 'Жаза бастаңыз...', + title_placeholder: 'Атауы', + messages: { + ui: { + blockTunes: { + toggler: { + 'Click to tune': 'Баптау үшін басыңыз', + 'or drag to move': 'немесе жылжыту үшін сүйреңіз' + } + }, + inlineToolbar: { converter: { 'Convert to': 'Түрлендіру' } }, + toolbar: { toolbox: { Add: 'Қосу' } }, + popover: { + Filter: 'Іздеу', + 'Nothing found': 'Ештеңе табылмады', + 'Convert to': 'Түрлендіру' + } + }, + toolNames: { + Text: 'Абзац', + Heading: 'Тақырып', + 'Ordered List': 'Нөмірленген тізім', + 'Unordered List': 'Таңбаланған тізім', + Warning: 'Ескерту', + Checklist: 'Тапсырмалар тізімі', + Quote: 'Дәйексөз', + Code: 'Код', + Delimiter: 'Бөлгіш', + 'Raw HTML': 'HTML үзіндісі', + Table: 'Кесте', + Link: 'Сілтеме', + Marker: 'Маркер', + Bold: 'Қалың', + Italic: 'Көлбеу', + InlineCode: 'Бірдей енді', + Image: 'Сурет' + }, + tools: { + paragraph: { 'Enter something': 'Мәтін енгізіңіз' }, + link: { 'Add a link': 'Сілтеме қосыңыз' }, + header: { 'Heading 1': 'Тақырып 1', 'Heading 2': 'Тақырып 2' } + }, + blockTunes: { + delete: { + Delete: 'Жою', + 'Click to delete': 'Жоюды растау' + }, + moveUp: { 'Move up': 'Жоғары' }, + moveDown: { 'Move down': 'Төмен' } + } + } + }, + ru: { placeholder: 'Начните писать...', title_placeholder: 'Заголовок', diff --git a/notes_editor/src/custom/runtime.js b/notes_editor/src/custom/runtime.js index 6caee1b3..33490977 100644 --- a/notes_editor/src/custom/runtime.js +++ b/notes_editor/src/custom/runtime.js @@ -26,8 +26,17 @@ function safeAndroidCall (func, ...args) { window.attachEditorInstance = function (instance) { editor = instance window.__EDITOR_READY = true + // The editor may start read-only (Android's "open in reading mode"); the title follows it. + isReadMode = !!instance.readOnly?.isEnabled + applyTitleEditable() } +/** + * Options Android passes in the page URL: whether a double tap in reading mode starts editing. + */ +const __pageParams = new URLSearchParams(window.location.search) +const __doubleTapToEdit = __pageParams.get('dbltap') === '1' + /** * Save current blocks and send to Android. */ @@ -55,6 +64,767 @@ function saveContent () { .catch(err => console.error('[Editor] Save failed:', err)) } +/** + * Send the document right now, without waiting for Editor.js to batch its changes. + * Android asks for this when the screen stops, so the last keystrokes are saved too. + */ +function flushContent () { + if (!editor) return Promise.resolve() + + return editor + .save() + .then(output => { + const jsonStr = JSON.stringify(output.blocks) + if (jsonStr === __lastSavedJson) return + __lastSavedJson = jsonStr + safeAndroidCall('onContentFlushed', jsonStr) + }) + .catch(err => console.error('[Editor] Flush failed:', err)) +} + +/** + * The page is about to be destroyed. Hands the undo history to Android first when asked (the + * screen is being recreated, for example rotated), then sends the document, and always ends with + * onPageFinished: Android keeps the WebView alive until then, so the last edit reaches it. + */ +function finishPage (keepHistory) { + const history = keepHistory ? exportHistory() : Promise.resolve() + return history + .catch(err => console.error('[History] export failed:', err)) + .then(() => flushContent()) + .finally(() => safeAndroidCall('onPageFinished')) +} + +/** + * Reading position: which block is at the top of the viewport once scrolling settles. + * Android keeps it so a rotation or recreation reopens the note where it was being read. + */ +let __anchorTimer = null + +function reportViewportAnchor () { + if (!editor) return + if (window.scrollY < 1) { + safeAndroidCall('onViewportAnchor', -1, 0) + return + } + const count = editor.blocks.getBlocksCount() + for (let i = 0; i < count; i++) { + const block = editor.blocks.getBlockByIndex(i) + const rect = block?.holder?.getBoundingClientRect() + if (rect && rect.bottom > 0) { + safeAndroidCall('onViewportAnchor', i, Math.round(rect.top)) + return + } + } +} + +window.addEventListener( + 'scroll', + () => { + clearTimeout(__anchorTimer) + __anchorTimer = setTimeout(reportViewportAnchor, 200) + }, + { passive: true } +) + +/** + * Where the note is being read or edited, for Android to keep per note on this device: the block + * at the top of the viewport and the block, input and character offset of the caret. Block ids + * survive edits and saves, so the position can be found again after the note changed. + * Reported once scrolling or the caret settles, and only after a reopened note's saved position + * has been applied, so the start of the note never overwrites it. + */ +let __viewStateTimer = null +let __viewStateReady = false + +function blockForHolder (holder) { + const count = editor.blocks.getBlocksCount() + for (let i = 0; i < count; i++) { + const block = editor.blocks.getBlockByIndex(i) + if (block?.holder === holder) return block + } + return null +} + +function editableInputs (holder) { + return [...holder.querySelectorAll('[contenteditable="true"]')] +} + +function caretPosition () { + if (!editor || isReadMode) return null + const selection = window.getSelection() + if (!selection || selection.rangeCount === 0 || !selection.focusNode) return null + const node = selection.focusNode + const element = node.nodeType === 1 ? node : node.parentElement + const input = element?.closest('[contenteditable="true"]') + const holder = input?.closest('.ce-block') + if (!holder) return null + const block = blockForHolder(holder) + if (!block) return null + const range = document.createRange() + range.selectNodeContents(input) + range.setEnd(node, selection.focusOffset) + return { + id: block.id, + input: Math.max(0, editableInputs(holder).indexOf(input)), + offset: range.toString().length + } +} + +function currentViewState () { + const scrollable = document.documentElement.scrollHeight - window.innerHeight + const state = { + scrollTop: Math.round(window.scrollY), + ratio: scrollable > 0 ? Math.min(1, window.scrollY / scrollable) : 0, + topId: null, + topIndex: -1, + topOffset: 0, + caret: caretPosition() + } + if (window.scrollY >= 1) { + const count = editor.blocks.getBlocksCount() + for (let i = 0; i < count; i++) { + const block = editor.blocks.getBlockByIndex(i) + const rect = block?.holder?.getBoundingClientRect() + if (rect && rect.bottom > 0) { + state.topId = block.id + state.topIndex = i + state.topOffset = Math.round(rect.top) + break + } + } + } + return state +} + +function reportViewState () { + if (!editor || !__viewStateReady) return + safeAndroidCall('onViewState', JSON.stringify(currentViewState())) +} + +// While scrolling goes on, the position is also reported every so often, not only once it +// settles: the note may be left in the middle of a fling, and in reading mode there is no caret +// to report it otherwise. +const VIEW_STATE_SETTLE_MS = 200 +const VIEW_STATE_WHILE_SCROLLING_MS = 250 +let __lastViewStateReport = 0 + +function reportViewStateNow () { + clearTimeout(__viewStateTimer) + __lastViewStateReport = Date.now() + reportViewState() +} + +function scheduleViewStateReport () { + clearTimeout(__viewStateTimer) + if (Date.now() - __lastViewStateReport >= VIEW_STATE_WHILE_SCROLLING_MS) { + __lastViewStateReport = Date.now() + reportViewState() + } + __viewStateTimer = setTimeout(reportViewStateNow, VIEW_STATE_SETTLE_MS) +} + +window.addEventListener('scroll', scheduleViewStateReport, { passive: true }) +document.addEventListener('selectionchange', scheduleViewStateReport) +document.addEventListener('visibilitychange', () => { + if (document.hidden) reportViewStateNow() +}) + +/** + * The position right now, for Android to save as the note is left; null until the note is shown + * and any saved position has been applied. + */ +function currentViewStateJson () { + if (!editor || !__viewStateReady) return null + return JSON.stringify(currentViewState()) +} + +/** + * Puts the caret {@code offset} characters into {@code input}, at its end when the text is now + * shorter, so a saved offset can never point outside the text. + */ +function placeCaret (input, offset) { + const walker = document.createTreeWalker(input, NodeFilter.SHOW_TEXT) + let remaining = Math.max(0, offset || 0) + let node = walker.nextNode() + let last = null + while (node) { + last = node + if (remaining <= node.length) break + remaining -= node.length + node = walker.nextNode() + } + const range = document.createRange() + if (node) range.setStart(node, remaining) + else if (last) range.setStart(last, last.length) + else range.setStart(input, 0) + range.collapse(true) + const selection = window.getSelection() + selection.removeAllRanges() + selection.addRange(range) +} + +/** + * Applies a saved position Android resolved against the note's current blocks: the caret first + * (placing it may scroll), then the block that was at the top of the viewport. + */ +function restoreViewState (state) { + if (!editor || !state) return + if (state.caretId && !isReadMode) { + const block = editor.blocks.getById(state.caretId) + if (block?.holder) { + try { + editor.caret.setToBlock(block, 'start') + } catch (e) { + console.error('[Editor] setToBlock failed:', e) + } + const inputs = editableInputs(block.holder) + const input = inputs[Math.min(state.caretInput || 0, inputs.length - 1)] + if (input) { + input.focus({ preventScroll: true }) + placeCaret(input, state.caretOffset) + } + } + } + let target = state.topId ? editor.blocks.getById(state.topId) : null + if (!target && state.topIndex >= 0) { + const count = editor.blocks.getBlocksCount() + if (count > 0) target = editor.blocks.getBlockByIndex(Math.min(state.topIndex, count - 1)) + } + if (!target?.holder) return + requestAnimationFrame(() => { + const top = target.holder.getBoundingClientRect().top + window.scrollTo(0, window.scrollY + top - (state.topOffset || 0)) + }) +} + +function restoreViewportAnchor (anchor) { + if (!editor || !anchor || anchor.index < 0) return + const count = editor.blocks.getBlocksCount() + if (count === 0) return + const block = editor.blocks.getBlockByIndex(Math.min(anchor.index, count - 1)) + if (!block?.holder) return + requestAnimationFrame(() => { + const top = block.holder.getBoundingClientRect().top + window.scrollTo(0, window.scrollY + top - (anchor.offset || 0)) + }) +} + +/** + * Keep the caret visible when the page height changes (the keyboard opens or closes). + * Only scrolls when the caret would otherwise be hidden, so nothing moves needlessly. + */ +function keepCaretVisible () { + const selection = window.getSelection() + if (!selection || selection.rangeCount === 0) return + const node = selection.focusNode + const element = node && (node.nodeType === 1 ? node : node.parentElement) + if (!element || !element.isContentEditable) return + + const range = selection.getRangeAt(0).cloneRange() + range.collapse(false) + let rect = range.getClientRects()[0] + if (!rect || (rect.top === 0 && rect.bottom === 0)) { + rect = element.getBoundingClientRect() + } + + const margin = 24 + const viewport = window.innerHeight + if (rect.bottom > viewport - margin) { + window.scrollBy(0, rect.bottom - viewport + margin) + } else if (rect.top < margin) { + window.scrollBy(0, rect.top - margin) + } +} + +window.addEventListener('resize', () => requestAnimationFrame(keepCaretVisible)) + +/** + * Undo and redo. + * + * The history is a list of whole document states (title and blocks), each with the caret that + * was in it. Changes are batched: a state is kept once typing pauses, a word or line is finished, + * a block is added, removed or moved, or a batch has run for a few seconds. Undo and redo apply a + * state with the smallest edit that reaches it: blocks whose content differs are updated in place + * and only a change in the list of blocks renders the document again, so the page does not jump. + * Each applied state is an ordinary change for Android, so it is autosaved like typing. + * + * The history belongs to the note on screen: loading a note starts it again, and it is never kept + * across notes. A page recreated with the screen (a rotation) takes over the history of the page + * it replaces, through Android, when it shows the same document. + */ +const HISTORY_LIMIT = 100 +const HISTORY_IDLE_MS = 700 +const HISTORY_MAX_BATCH_MS = 3000 +// Editor.js reports a change up to 400 ms after it happened; a report that late about a state +// this history applied itself is not a new change. +const HISTORY_SETTLE_MS = 600 + +const __hist = { + undo: [], + redo: [], + current: null, + pending: false, + // Whether the open batch has typed anything other than whitespace. + batchHasText: false, + caretBefore: null, + lastCaret: null, + batchStart: 0, + timer: null, + applying: false, + settleUntil: 0, + epoch: 0, + changeSeq: 0, + queue: Promise.resolve(), + reported: '' +} + +function historyTitleElement () { + return document.getElementById('noteTitleInput') +} + +/** The caret as { title: true, offset } in the title, or { id, input, offset } in a block. */ +function historyCaret () { + if (!editor || isReadMode) return null + const selection = window.getSelection() + if (!selection || selection.rangeCount === 0 || !selection.focusNode) return null + const title = historyTitleElement() + if (title && title.contains(selection.focusNode)) { + const range = document.createRange() + range.selectNodeContents(title) + range.setEnd(selection.focusNode, selection.focusOffset) + return { title: true, offset: range.toString().length } + } + return caretPosition() +} + +function readDocument () { + return editor.save().then(output => ({ + title: historyTitleElement()?.innerText || '', + blocks: output.blocks + })) +} + +function historyEntry (doc, caret) { + return { doc, json: JSON.stringify(doc), caret } +} + +function reportHistory () { + const canUndo = __hist.undo.length > 0 || __hist.pending + const canRedo = __hist.redo.length > 0 && !__hist.pending + const key = `${canUndo}|${canRedo}` + if (key === __hist.reported) return + __hist.reported = key + safeAndroidCall('onHistoryChanged', canUndo, canRedo) +} + +/** Attachment URLs a document refers to. */ +function attachmentUrls (doc) { + const urls = new Set() + for (const block of doc?.blocks || []) { + const data = block?.data + if (data?.file?.url) urls.add(data.file.url) + if (Array.isArray(data?.files)) { + for (const file of data.files) if (file?.url) urls.add(file.url) + } + } + return urls +} + +/** + * Files a step takes out of the note may come back with an undo. Android removes files no saved + * note refers to, so it is told to keep these. + */ +function keepAttachmentsForUndo (fromDoc, toDoc) { + const kept = attachmentUrls(toDoc) + const leaving = [...attachmentUrls(fromDoc)].filter(url => !kept.has(url)) + if (leaving.length > 0) safeAndroidCall('keepForUndo', JSON.stringify(leaving)) +} + +/** Forgets the history; nothing is recorded until resetHistory() reads the new document. */ +function stopHistory () { + __hist.epoch++ + clearTimeout(__hist.timer) + __hist.timer = null + __hist.undo = [] + __hist.redo = [] + __hist.current = null + __hist.pending = false + __hist.caretBefore = null + __hist.lastCaret = null + __hist.applying = false + __hist.settleUntil = 0 + reportHistory() +} + +/** + * Largest history handed over to a recreated page, in characters of document JSON. The oldest + * undo steps and the farthest redo steps are left out first. + */ +const HISTORY_EXPORT_MAX_CHARS = 1500000 + +// History handed over by the page this one replaces; used when the history starts on the same +// document, then dropped. +let __historyToImport = null + +function exportedEntry (entry) { + return { json: entry.json, caret: entry.caret || null } +} + +function importedEntry (entry) { + return historyEntry(JSON.parse(entry.json), entry.caret || null) +} + +/** Sends the history to Android (onHistoryExported) to be given to the next page. */ +function exportHistory () { + return runHistoryAction(() => + (isReadMode ? Promise.resolve() : captureHistory(true)).then(() => { + if (!__hist.current) return + const undo = __hist.undo.map(exportedEntry) + const redo = __hist.redo.map(exportedEntry) + const size = list => list.reduce((sum, e) => sum + e.json.length, 0) + let total = __hist.current.json.length + size(undo) + size(redo) + while (total > HISTORY_EXPORT_MAX_CHARS && (undo.length || redo.length)) { + // The oldest undo step is first; the farthest redo step is first too. + const dropped = undo.length >= redo.length ? undo.shift() : redo.shift() + total -= dropped.json.length + } + if (total > HISTORY_EXPORT_MAX_CHARS) return + safeAndroidCall( + 'onHistoryExported', + JSON.stringify({ current: exportedEntry(__hist.current), undo, redo }) + ) + }) + ) +} + +/** Takes over a handed-over history when it was recorded on the document now on screen. */ +function importHistory () { + const imported = __historyToImport + __historyToImport = null + if (!imported?.current || imported.current.json !== __hist.current?.json) return + try { + __hist.undo = (imported.undo || []).map(importedEntry).slice(-HISTORY_LIMIT) + __hist.redo = (imported.redo || []).map(importedEntry).slice(-HISTORY_LIMIT) + __hist.current.caret = imported.current.caret || null + } catch (e) { + console.error('[History] import failed:', e) + __hist.undo = [] + __hist.redo = [] + } +} + +/** + * Starts the history again from the document now on screen. Editor.js refuses to save while it is + * read-only, so a note shown in reading mode has no history until editing starts; setReadMode() + * starts it then. + * + * @param settle ignore Editor.js's late change reports for a moment, for a document it has just + * rendered again. + */ +function resetHistory (settle) { + stopHistory() + if (!editor || isReadMode) return + const epoch = __hist.epoch + readDocument() + .then(doc => { + if (epoch !== __hist.epoch) return + __hist.current = historyEntry(doc, null) + if (settle) __hist.settleUntil = Date.now() + HISTORY_SETTLE_MS + importHistory() + reportHistory() + }) + .catch(err => console.error('[History] reset failed:', err)) +} + +/** + * Something in the note changed, or is about to. {@code immediate} closes the batch now. + * {@code fromEditor} marks Editor.js's own late change report. + */ +function historyNoteChange (immediate, fromEditor) { + if (!__hist.current || __hist.applying || isReadMode) return + if (fromEditor && Date.now() < __hist.settleUntil) return + __hist.changeSeq++ + if (!__hist.pending) { + __hist.pending = true + __hist.batchHasText = false + __hist.batchStart = Date.now() + if (!__hist.caretBefore) __hist.caretBefore = __hist.lastCaret + } + clearTimeout(__hist.timer) + const overdue = Date.now() - __hist.batchStart >= HISTORY_MAX_BATCH_MS + __hist.timer = setTimeout(captureHistory, immediate || overdue ? 0 : HISTORY_IDLE_MS) + reportHistory() +} + +/** Called before a key or an input changes the note: remembers where the caret was. */ +function historyInputStarting () { + if (!__hist.current || __hist.applying || isReadMode) return + if (!__hist.pending && !__hist.caretBefore) __hist.caretBefore = historyCaret() +} + +/** + * Keeps the document as a new state if it differs from the last one. With {@code force} the + * document is compared even when no change was reported. + */ +function captureHistory (force) { + clearTimeout(__hist.timer) + __hist.timer = null + if (!editor || !__hist.current || (!__hist.pending && !force)) return Promise.resolve() + const epoch = __hist.epoch + const seq = __hist.changeSeq + return readDocument().then(doc => { + if (epoch !== __hist.epoch || !__hist.current) return + const caretBefore = __hist.caretBefore + const changedMeanwhile = seq !== __hist.changeSeq + __hist.pending = changedMeanwhile + __hist.caretBefore = null + if (changedMeanwhile) __hist.batchStart = Date.now() + else __hist.batchHasText = false + const entry = historyEntry(doc, historyCaret()) + if (entry.json !== __hist.current.json) { + keepAttachmentsForUndo(__hist.current.doc, doc) + __hist.undo.push({ ...__hist.current, caret: caretBefore || __hist.current.caret }) + if (__hist.undo.length > HISTORY_LIMIT) __hist.undo.shift() + __hist.redo = [] + __hist.current = entry + } + reportHistory() + }) +} + +/** Whether two block lists hold the same blocks, by id and type, in the same order. */ +function sameBlockList (a, b) { + if (a.length !== b.length) return false + for (let i = 0; i < a.length; i++) { + if (a[i].id !== b[i].id || a[i].type !== b[i].type) return false + } + return true +} + +/** + * Whether the editor shows exactly the blocks of {@code blocks}. Saving leaves out blocks with + * nothing in them (an empty paragraph), so the saved document and the screen can differ. + */ +function editorShows (blocks) { + if (editor.blocks.getBlocksCount() !== blocks.length) return false + for (let i = 0; i < blocks.length; i++) { + if (editor.blocks.getBlockByIndex(i)?.id !== blocks[i].id) return false + } + return true +} + +/** The block with this id, or null; unlike getById it does not warn about a missing one. */ +function findBlock (id) { + if (!id) return null + const count = editor.blocks.getBlocksCount() + for (let i = 0; i < count; i++) { + const block = editor.blocks.getBlockByIndex(i) + if (block?.id === id) return block + } + return null +} + +function restoreHistoryCaret (caret, keep, fallbackId) { + const title = historyTitleElement() + if (caret?.title && title) { + title.focus({ preventScroll: true }) + placeCaret(title, caret.offset) + } else if (caret?.id && findBlock(caret.id)) { + restoreViewState({ + caretId: caret.id, + caretInput: caret.input, + caretOffset: caret.offset, + topId: findBlock(keep?.topId) ? keep.topId : null, + topIndex: keep ? keep.topIndex : -1, + topOffset: keep?.topOffset || 0 + }) + } else { + const block = findBlock(fallbackId) + if (block) { + try { + editor.caret.setToBlock(block, 'end') + } catch (e) { + console.error('[History] placing the caret failed:', e) + } + } + if (keep) { + restoreViewState({ + topId: findBlock(keep.topId) ? keep.topId : null, + topIndex: keep.topIndex, + topOffset: keep.topOffset + }) + } + } + // restoreViewState scrolls on the next frame; the caret is brought into view after that. + requestAnimationFrame(() => requestAnimationFrame(keepCaretVisible)) +} + +/** Puts the document of {@code entry} on screen with the smallest change that gets there. */ +function applyHistoryEntry (entry) { + const from = __hist.current.doc + const to = entry.doc + const epoch = __hist.epoch + __hist.applying = true + keepAttachmentsForUndo(from, to) + + const title = historyTitleElement() + if (title && from.title !== to.title) { + title.innerText = to.title + // The title's own listeners update its placeholder and tell Android. + title.dispatchEvent(new Event('input')) + } + + let keep = null + let changedId = null + let work + if (sameBlockList(from.blocks, to.blocks) && editorShows(from.blocks)) { + const updates = [] + for (let i = 0; i < to.blocks.length; i++) { + if (JSON.stringify(from.blocks[i]) === JSON.stringify(to.blocks[i])) continue + changedId = to.blocks[i].id + updates.push(editor.blocks.update(to.blocks[i].id, to.blocks[i].data, to.blocks[i].tunes)) + } + work = Promise.all(updates) + } else { + keep = currentViewState() + work = editor.render({ blocks: to.blocks }) + } + + return work + .then(() => readDocument()) + .then(doc => { + if (epoch !== __hist.epoch) return + __hist.current = historyEntry(doc, entry.caret) + __hist.settleUntil = Date.now() + HISTORY_SETTLE_MS + restoreHistoryCaret(entry.caret, keep, changedId) + saveContent() + }) + .catch(err => console.error('[History] applying a step failed:', err)) + .finally(() => { + if (epoch === __hist.epoch) __hist.applying = false + reportHistory() + }) +} + +function runHistoryAction (action) { + __hist.queue = __hist.queue.then(action).catch(err => console.error('[History]', err)) + return __hist.queue +} + +function historyUndo () { + if (!editor || isReadMode) return + runHistoryAction(() => + captureHistory(true).then(() => { + const entry = __hist.undo.pop() + if (!entry || !__hist.current) return + __hist.redo.push({ ...__hist.current, caret: historyCaret() || __hist.current.caret }) + return applyHistoryEntry(entry) + }) + ) +} + +function historyRedo () { + if (!editor || isReadMode) return + runHistoryAction(() => + captureHistory(true).then(() => { + const entry = __hist.redo.pop() + if (!entry || !__hist.current) return + __hist.undo.push({ ...__hist.current, caret: historyCaret() || __hist.current.caret }) + return applyHistoryEntry(entry) + }) + ) +} + +/** + * A block Android has deleted together with its file is taken out of every state, so no undo + * can bring back a block whose file is gone. Steps left with nothing to change are dropped. + */ +function forgetHistoryBlock (blockId) { + const strip = entry => { + const blocks = entry.doc.blocks.filter(block => block.id !== blockId) + if (blocks.length === entry.doc.blocks.length) return entry + return historyEntry({ title: entry.doc.title, blocks }, entry.caret) + } + const distinct = list => list.filter((entry, i) => i === 0 || entry.json !== list[i - 1].json) + if (__hist.current) __hist.current = strip(__hist.current) + const undo = distinct(__hist.undo.map(strip)) + if (__hist.current && undo.length && undo[undo.length - 1].json === __hist.current.json) undo.pop() + // The next redo is the last element; walk them from the current state outwards. + const redo = distinct(__hist.redo.map(strip).reverse()) + if (__hist.current && redo.length && redo[0].json === __hist.current.json) redo.shift() + __hist.undo = undo + __hist.redo = redo.reverse() + reportHistory() +} + +/** Editor.js reported changes; a new, removed or moved block closes the batch at once. */ +function historyEditorChanged (event) { + const events = Array.isArray(event) ? event : [event] + const structural = events.some(e => e && e.type && e.type !== 'block-changed') + historyNoteChange(structural, true) +} + +function isHistoryShortcut (event) { + if (!(event.ctrlKey || event.metaKey) || event.altKey) return null + const key = (event.key || '').toLowerCase() + if (key === 'z') return event.shiftKey ? 'redo' : 'undo' + if (key === 'y' && !event.shiftKey) return 'redo' + return null +} + +function isEditingKey (event) { + if (event.ctrlKey || event.metaKey || event.altKey) return false + if (event.keyCode === 229) return true + const key = event.key || '' + return key.length === 1 || key === 'Enter' || key === 'Backspace' || key === 'Delete' || key === 'Tab' +} + +document.addEventListener( + 'keydown', + event => { + const action = isHistoryShortcut(event) + if (action) { + event.preventDefault() + event.stopPropagation() + if (action === 'undo') historyUndo() + else historyRedo() + return + } + if (isEditingKey(event)) { + historyInputStarting() + if (event.key === 'Enter') historyNoteChange(true, false) + } + }, + true +) + +document.addEventListener( + 'beforeinput', + event => { + if (event.inputType === 'historyUndo' || event.inputType === 'historyRedo') { + // The browser's own undo knows nothing about blocks; ours replaces it. + event.preventDefault() + if (event.inputType === 'historyUndo') historyUndo() + else historyRedo() + return + } + historyInputStarting() + // A finished word or line closes the batch, the space or line break with it; a space that + // only follows a pause stays with the word typed after it. + const data = typeof event.data === 'string' ? event.data : '' + const lineBreak = event.inputType === 'insertParagraph' || event.inputType === 'insertLineBreak' + const endsWord = lineBreak || (/\s$/.test(data) && (__hist.batchHasText || /\S/.test(data))) + historyNoteChange(endsWord, false) + if (/\S/.test(data)) __hist.batchHasText = true + }, + true +) + +document.addEventListener('selectionchange', () => { + if (!__hist.pending && !__hist.applying) __hist.lastCaret = historyCaret() +}) + /** * Apply theme colors from Android. */ @@ -89,10 +859,44 @@ function loadNote (note) { blocks = note.valueJson } + __viewStateReady = false + // Undo never reaches into another note, or into this one as it was before a reload; only a + // page recreated on the same document takes over its predecessor's history. + stopHistory() + __historyToImport = note.history || null editor.render({ blocks }).then(() => { - __lastSavedJson = JSON.stringify(blocks) + __lastSavedJson = JSON.stringify(blocks) + resetHistory() + if (note.viewState) restoreViewState(note.viewState) + else restoreViewportAnchor(note.anchor) + // "Open in editing mode" with no caret to restore: the caret starts at the block shown at + // the top, or at the first block. The saved scroll position is applied after this. + if (note.focusStart && !isReadMode && !note.viewState?.caretId) { + const vs = note.viewState + let block = vs?.topId ? editor.blocks.getById(vs.topId) : null + if (!block && vs?.topIndex >= 0) { + const count = editor.blocks.getBlocksCount() + if (count > 0) block = editor.blocks.getBlockByIndex(Math.min(vs.topIndex, count - 1)) + } + try { + if (block) editor.caret.setToBlock(block, 'start') + else editor.caret.setToFirstBlock('start') + } catch (e) { + console.error('[Editor] placing the caret failed:', e) + } + } + // No saved position (the note opens at its start): the title is on screen, whatever placing + // the caret scrolled. + if (!note.viewState && !(note.anchor?.index >= 0)) window.scrollTo(0, 0) + safeAndroidCall('onNoteRendered') + // Both restores scroll on the next frame; start reporting once that has happened. + requestAnimationFrame(() => + requestAnimationFrame(() => { + __viewStateReady = true + reportViewState() + }) + ) }) - } /** @@ -107,42 +911,113 @@ function fileToBase64 (file) { }) } +/** + * Uploads run on Android's side, off the JS thread. Each request gets an id; Android answers + * through window.__onUploadFinished(id, result) once the file is stored. A synchronous bridge + * call carrying the whole file used to block the page for the length of the save, which froze + * the editor and made it jump when it caught up. + */ +const __pendingUploads = new Map() +let __uploadSeq = 0 + +function requestUpload (kind, file) { + return new Promise(resolve => { + const id = `u${Date.now()}_${++__uploadSeq}` + __pendingUploads.set(id, resolve) + + // A file from the system picker: Android already holds its content URI and reads it itself. + const started = safeAndroidCall( + 'requestPickedUpload', + id, + kind, + file.name || '', + file.size || 0 + ) + if (started === true) return + + // Pasted or dropped: hand the bytes over; Android stores them in the background. + fileToBase64(file) + .then(base64 => { + const accepted = safeAndroidCall( + 'uploadBase64Async', + id, + kind, + base64, + file.name || 'file' + ) + if (accepted !== true) finishUpload(id, null) + }) + .catch(() => finishUpload(id, null)) + }) +} + +function finishUpload (id, result) { + const resolve = __pendingUploads.get(id) + if (!resolve) return + __pendingUploads.delete(id) + resolve(result) +} + +window.__onUploadFinished = finishUpload + /** * Upload attachment via Android and return Editor.js result. */ async function uploadAttachment (file) { - const base64 = await fileToBase64(file) - const url = safeAndroidCall('uploadFile', base64, file.name) - + const result = await requestUpload('file', file) + if (!result || !result.success || !result.file) { + return { success: 0, file: null } + } return { - success: url ? 1 : 0, - file: url - ? { - url, - name: file.name, - size: file.size, - extension: file.name.split('.').pop() - } - : null + success: 1, + file: { + url: result.file.url, + name: file.name, + size: file.size, + extension: file.name.split('.').pop() + } } } /** - * Upload image via Android and return ImageTool format. + * Upload image via Android and return ImageTool format. The stored image's size travels with + * the block, so its space is reserved before it has loaded, now and every time the note opens. */ async function uploadImage (file) { - const base64 = await fileToBase64(file) - const respJson = safeAndroidCall('uploadImage', base64, file.name) - - if (!respJson) { + const result = await requestUpload('image', file) + if (!result || !result.success || !result.file) { return { success: 0 } } + return { success: 1, file: result.file } +} - try { - return JSON.parse(respJson) - } catch (e) { - console.error('[ImageUpload] Invalid JSON:', respJson) - return { success: 0 } +/** + * Index of the block holding the caret, so a picked file can be put back in the same place if + * the screen is recreated while the picker is open. + */ +function currentBlockIndex () { + if (!editor) return -1 + return editor.blocks.getCurrentBlockIndex() +} + +/** + * Inserts a block for a file Android stored after the page was recreated (the picker answered + * a page that no longer exists). + */ +function insertUploadedBlockFromAndroid (kind, file, index) { + if (!editor || !file || !file.url) return + const count = editor.blocks.getBlocksCount() + const at = index >= 0 && index <= count ? index : count + if (kind === 'image') { + editor.blocks.insert('image', { file }, undefined, at, false) + } else { + editor.blocks.insert( + 'attaches', + { file, title: file.name || '' }, + undefined, + at, + false + ) } } @@ -161,6 +1036,8 @@ window.deleteAttachmentBlockFromAndroid = function (blockId, fileUrl) { if (index < 0) return editor.blocks.delete(index) + // Its file is deleted next; no undo may bring the block back without it. + forgetHistoryBlock(blockId) setTimeout(() => { safeAndroidCall('onAttachmentBlockDeletedResponse', blockId, fileUrl) @@ -171,12 +1048,13 @@ window.deleteAttachmentBlockFromAndroid = function (blockId, fileUrl) { } /** - * Placeholder logic for title + * Shows the title hint while the title is empty, in the page's language (editor-init sets it + * from the locale Android passes). */ function updateTitlePlaceholder () { const titleEl = document.getElementById('noteTitleInput') if (!titleEl.innerText.trim()) { - titleEl.setAttribute('data-placeholder', 'Title...') + titleEl.setAttribute('data-placeholder', window.__titlePlaceholder || '') } else { titleEl.removeAttribute('data-placeholder') } @@ -187,20 +1065,126 @@ const titleEl = document.getElementById('noteTitleInput') titleEl.addEventListener('input', () => { updateTitlePlaceholder() safeAndroidCall('onTitleChanged', titleEl.innerText.trim()) + historyNoteChange(false, false) }) +/** + * The title is edited outside Editor.js, so reading mode has to lock it separately. + */ +function applyTitleEditable () { + const title = document.getElementById('noteTitleInput') + if (!title) return + title.setAttribute('contenteditable', isReadMode ? 'false' : 'true') + if (isReadMode && document.activeElement === title) title.blur() +} + +/** + * Switches reading mode on or off. Editor.js renders every block again, so the block at the top + * of the viewport is put back afterwards and the page does not jump. Android hears about every + * change, including one made by a double tap, so its toolbar always shows the actual mode. + * + * @param readOnly the mode to switch to. + * @param caret where to put the caret once editing is on ({ id, input, offset }), or null. + */ +function setReadMode (readOnly, caret) { + if (!editor?.readOnly) return Promise.resolve(isReadMode) + const keep = currentViewState() + isReadMode = readOnly + applyTitleEditable() + return editor.readOnly.toggle(readOnly).then(() => { + // Opened in reading mode, the note had no history to record edits into. + if (!readOnly && !__hist.current) resetHistory(true) + restoreViewState({ + caretId: !readOnly && caret ? caret.id : null, + caretInput: caret ? caret.input : 0, + caretOffset: caret ? caret.offset : 0, + topId: keep.topId, + topIndex: keep.topIndex, + topOffset: keep.topOffset + }) + safeAndroidCall('onReadModeChanged', readOnly, !!caret) + return readOnly + }) +} + +/** + * Android is about to open the keyboard: makes sure the editor holds a caret for it to type at. + * A caret the page already has (a restored position, a tap) is kept; otherwise it goes to the + * start of the first block, without moving the page. + */ +function focusCaretFromAndroid () { + if (!editor || isReadMode) return + const active = document.activeElement + if (active && active.isContentEditable) { + active.focus({ preventScroll: true }) + return + } + const y = window.scrollY + try { + editor.caret.setToFirstBlock('start') + } catch (e) { + console.error('[Editor] placing the caret failed:', e) + } + window.scrollTo(0, y) +} + /** * Toggle read-only mode */ function toggleReadModeFromAndroid () { - isReadMode = !isReadMode - if (editor?.readOnly) editor.readOnly.toggle() + setReadMode(!isReadMode, null) } +/** + * The text position under a point in reading mode, as { id, input, offset }: the block, which of + * its inputs (a list has one per item) and how many characters into it. + */ +function positionAtPoint (x, y) { + const range = document.caretRangeFromPoint?.(x, y) + if (!range) return null + const node = range.startContainer + const element = node.nodeType === 1 ? node : node.parentElement + const holder = element?.closest('.ce-block') + if (!holder) return null + const block = blockForHolder(holder) + if (!block) return null + const inputs = [...holder.querySelectorAll('[contenteditable]')] + const input = inputs.find(candidate => candidate.contains(node)) + if (!input) return { id: block.id, input: 0, offset: 0 } + const before = document.createRange() + before.selectNodeContents(input) + before.setEnd(node, range.startOffset) + return { + id: block.id, + input: inputs.indexOf(input), + offset: before.toString().length + } +} + +document.addEventListener('dblclick', event => { + if (!__doubleTapToEdit || !isReadMode || !editor) return + const position = positionAtPoint(event.clientX, event.clientY) + // A double tap selects a word; editing starts with a plain caret instead. + window.getSelection()?.removeAllRanges() + if (!position) return + event.preventDefault() + setReadMode(false, position) +}) + window.setThemeColors = setThemeColors window.loadNote = loadNote window.uploadAttachment = uploadAttachment window.toggleReadModeFromAndroid = toggleReadModeFromAndroid +window.setReadMode = setReadMode window.saveContent = saveContent +window.currentBlockIndex = currentBlockIndex +window.insertUploadedBlockFromAndroid = insertUploadedBlockFromAndroid +window.flushContent = flushContent +window.finishPage = finishPage +window.currentViewStateJson = currentViewStateJson +window.focusCaretFromAndroid = focusCaretFromAndroid +window.historyUndo = historyUndo +window.historyRedo = historyRedo +window.historyEditorChanged = historyEditorChanged // expose globally window.uploadImage = uploadImage diff --git a/notes_editor/src/editor-init.js b/notes_editor/src/editor-init.js index 7a1e903a..55a9d459 100644 --- a/notes_editor/src/editor-init.js +++ b/notes_editor/src/editor-init.js @@ -28,16 +28,23 @@ window.SpacerTool = SpacerTool global.__saveTimer = null const titleDiv = document.getElementById('noteTitleInput') + // The title hint, also for runtime.js, which shows it again whenever a note is loaded. + global.__titlePlaceholder = i18n.title_placeholder const AttachesTool = global.AttachesTool + // Android turns autofocus off when it is about to put the caret back where the note was + // left; the first block would otherwise take focus and the keyboard first. + const params = new URLSearchParams(global.location.search) + const editor = new EditorJS({ holder: 'editorjs', i18n, placeholder: i18n.placeholder, - autofocus: true, - readOnly: false, + autofocus: params.get('autofocus') !== '0', + // "Open in reading mode": rendered read-only from the start, with no switch to see. + readOnly: params.get('readonly') === '1', tools: { paragraph: { class: Paragraph, inlineToolbar: true }, @@ -92,13 +99,14 @@ window.SpacerTool = SpacerTool global.Android?.onEditorReady() - updateTitlePlaceholder(i18n.title_placeholder || 'Title...') + updateTitlePlaceholder(global.__titlePlaceholder) }, - onChange () { + onChange (api, event) { if (!global.__EDITOR_READY) return - global.saveContent?.() + global.saveContent?.() + global.historyEditorChanged?.(event) } }) @@ -109,7 +117,7 @@ window.SpacerTool = SpacerTool } titleDiv.addEventListener('input', () => { - updateTitlePlaceholder(i18n.title_placeholder || 'Title...') + updateTitlePlaceholder(global.__titlePlaceholder) global.Android?.onTitleChanged(titleDiv.innerText.trim()) }) diff --git a/notes_editor/src/editor.css b/notes_editor/src/editor.css index 028934ea..47b3a58f 100644 --- a/notes_editor/src/editor.css +++ b/notes_editor/src/editor.css @@ -18,7 +18,9 @@ body { margin: 0; padding: 16px; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; - background-color: var(--surface-color, #ffffff); + /* Transparent until Android sends the theme: the WebView's own background, set from the + app theme before the page loads, shows through instead of a white flash. */ + background-color: var(--surface-color, transparent); color: var(--on-surface-color, #000000); } .codex-editor { @@ -267,3 +269,28 @@ body { } .ce-spacer-m { height: 25px } + +/* Images: the box keeps the picture's proportions from the moment it is known, so inserting or + loading an image never changes the height of the note under the caret. */ +.image-tool--uploading .image-tool__image { + background-color: var(--surface-variant-color, transparent) !important; + border-color: var(--outline-color, rgba(201, 201, 204, 0.48)) !important; + border-radius: 12px; +} + +.image-tool--reserved:not(.image-tool--filled) .image-tool__image { + display: flex; + align-items: center; + width: 100%; + min-height: 0; + aspect-ratio: var(--reserved-ratio); + background-color: var(--surface-variant-color, transparent); + border-radius: 12px; + box-sizing: border-box; +} + +.image-tool--uploading .image-tool__image-preloader { + background-color: var(--surface-color, transparent); + --bg-color: var(--surface-variant-color, #cdd1e0); + --front-color: var(--primary-color, #388ae5); +} diff --git a/notes_editor/src/tools/ImageToolClickable.js b/notes_editor/src/tools/ImageToolClickable.js index 3be3899e..0969de29 100644 --- a/notes_editor/src/tools/ImageToolClickable.js +++ b/notes_editor/src/tools/ImageToolClickable.js @@ -3,14 +3,43 @@ import ImageTool from '@editorjs/image'; /** * ImageToolClickable * ------------------- - * Extends the standard ImageTool so that photos can be opened with a **long press**. - * The logic does not break the original ImageTool, does not interfere with upload or render(), - * it simply attaches an additional listener to the block wrapper. + * Extends the standard ImageTool so that photos can be opened with a **long press**, and so an + * image never changes the height of the note once it appears. + * + * The stored image's width and height travel in the block data (file.width / file.height). With + * them the block reserves exactly the space the picture will take before it has loaded: while it + * uploads, while it loads on opening the note, and when the upload finishes. Without them the + * block grew from nothing (or from a 200 px placeholder) to the picture's height, pushing the + * text the user was editing up or down. */ export default class ImageToolClickable extends ImageTool { + constructor(params) { + super(params); + // While a picked image uploads, its preview already tells its proportions: reserve them + // straight away instead of showing a fixed-height placeholder that then resizes. + const showPreloader = this.ui.showPreloader.bind(this.ui); + this.ui.showPreloader = (src) => { + showPreloader(src); + this.reserveFromPreview(src); + }; + } + + reserveFromPreview(src) { + if (!src) return; + const probe = new Image(); + probe.onload = () => { + const wrapper = this.ui?.nodes?.wrapper; + if (wrapper && !wrapper.classList.contains('image-tool--reserved')) { + this.reserveSpace({ width: probe.naturalWidth, height: probe.naturalHeight }); + } + }; + probe.src = src; + } + render() { const wrapper = super.render(); + this.reserveSpace(this.data?.file); // Set long-press listener only once if (!wrapper.__longPressBound) { wrapper.__longPressBound = true; @@ -43,4 +72,25 @@ export default class ImageToolClickable extends ImageTool { return wrapper; } + + onUpload(response) { + if (response?.success && response.file) { + this.reserveSpace(response.file); + } + super.onUpload(response); + } + + /** + * Gives the image box the picture's aspect ratio until the picture itself is shown. + * + * @param {{width?: number, height?: number}} file stored image data + */ + reserveSpace(file) { + const width = Number(file?.width); + const height = Number(file?.height); + const wrapper = this.ui?.nodes?.wrapper; + if (!wrapper || !(width > 0) || !(height > 0)) return; + wrapper.style.setProperty('--reserved-ratio', `${width} / ${height}`); + wrapper.classList.add('image-tool--reserved'); + } } diff --git a/notes_editor/test/editor.test.mjs b/notes_editor/test/editor.test.mjs new file mode 100644 index 00000000..b953d57c --- /dev/null +++ b/notes_editor/test/editor.test.mjs @@ -0,0 +1,45 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { openPage, fakeEditor, settle } from './page.mjs' + +test('the title hint is the localized one, also after a note is loaded', async () => { + const page = openPage() + page.window.__titlePlaceholder = 'Назва' + const { editor } = fakeEditor([]) + page.window.attachEditorInstance(editor) + page.window.loadNote({ title: '', valueJson: [] }) + await settle() + + assert.equal(page.title.getAttribute('data-placeholder'), 'Назва') +}) + +test('opening the keyboard gives the editor a caret when it has none', async () => { + const page = openPage() + const { editor } = fakeEditor([{ id: 'a', type: 'paragraph', data: { text: 'x' } }]) + let placed = 0 + editor.caret.setToFirstBlock = () => { + placed++ + page.window.scrollY = 300 + } + page.window.attachEditorInstance(editor) + + page.window.focusCaretFromAndroid() + assert.equal(placed, 1) + // Placing it does not move the page. + assert.equal(page.window.scrollY, 0) + + page.document.activeElement = { isContentEditable: true, focus () {} } + page.window.focusCaretFromAndroid() + assert.equal(placed, 1) +}) + +test('in reading mode no caret is placed', async () => { + const page = openPage('?readonly=1') + const { editor } = fakeEditor([], { readOnly: true }) + let placed = 0 + editor.caret.setToFirstBlock = () => placed++ + page.window.attachEditorInstance(editor) + + page.window.focusCaretFromAndroid() + assert.equal(placed, 0) +}) diff --git a/notes_editor/test/history.test.mjs b/notes_editor/test/history.test.mjs new file mode 100644 index 00000000..1890a601 --- /dev/null +++ b/notes_editor/test/history.test.mjs @@ -0,0 +1,89 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { openPage, fakeEditor, settle } from './page.mjs' + +test('a note opened in reading mode records edits once editing starts', async () => { + const page = openPage('?readonly=1') + const { editor, state } = fakeEditor([{ id: 'a', type: 'paragraph', data: { text: 'x' } }], { + readOnly: true + }) + page.window.attachEditorInstance(editor) + page.window.loadNote({ title: 'T', valueJson: state.blocks }) + await settle() + + await page.window.setReadMode(false, null) + await settle() + state.blocks[0].data = { text: 'xy' } + page.fire('document', 'beforeinput', { inputType: 'insertText', data: 'y' }) + await settle() + + assert.deepEqual(page.callsTo('onHistoryChanged').at(-1), [true, false]) +}) + +async function editedPage () { + const page = openPage() + const { editor, state } = fakeEditor([{ id: 'a', type: 'paragraph', data: { text: 'one' } }]) + page.window.attachEditorInstance(editor) + page.window.loadNote({ title: 'T', valueJson: state.blocks }) + await settle() + state.blocks[0].data = { text: 'one two' } + page.fire('document', 'beforeinput', { inputType: 'insertText', data: ' ' }) + await settle() + return { page, editor, state } +} + +test('a recreated page takes over the undo history of the page it replaces', async () => { + const before = await editedPage() + await before.page.window.finishPage(true) + + const names = before.page.calls.map(c => c[0]) + assert.ok(names.indexOf('onHistoryExported') < names.indexOf('onPageFinished')) + const [exported] = before.page.callsTo('onHistoryExported').at(-1) + + const after = openPage() + const { editor, state } = fakeEditor(before.state.blocks) + after.window.attachEditorInstance(editor) + after.window.loadNote({ title: 'T', valueJson: state.blocks, history: JSON.parse(exported) }) + await settle() + assert.deepEqual(after.callsTo('onHistoryChanged').at(-1), [true, false]) + + after.window.historyUndo() + await settle() + assert.equal(state.blocks[0].data.text, 'one') +}) + +test('a handed-over history recorded on another document is dropped', async () => { + const before = await editedPage() + await before.page.window.finishPage(true) + const [exported] = before.page.callsTo('onHistoryExported').at(-1) + + const after = openPage() + const { editor, state } = fakeEditor([{ id: 'a', type: 'paragraph', data: { text: 'other' } }]) + after.window.attachEditorInstance(editor) + after.window.loadNote({ title: 'T', valueJson: state.blocks, history: JSON.parse(exported) }) + await settle() + assert.deepEqual(after.callsTo('onHistoryChanged').at(-1), [false, false]) +}) + +test('finishing the page always answers, even when the document cannot be saved', async () => { + const page = openPage('?readonly=1') + const { editor, state } = fakeEditor([{ id: 'a', type: 'paragraph', data: {} }], { + readOnly: true + }) + page.window.attachEditorInstance(editor) + page.window.loadNote({ title: 'T', valueJson: state.blocks }) + await settle() + + await page.window.finishPage(true) + assert.equal(page.callsTo('onPageFinished').length, 1) +}) + +test('the last edit is sent before the page reports it is finished', async () => { + const { page, state } = await editedPage() + state.blocks[0].data = { text: 'one two three' } + await page.window.finishPage(false) + + const names = page.calls.map(c => c[0]) + assert.ok(names.lastIndexOf('onContentFlushed') < names.indexOf('onPageFinished')) + assert.match(page.callsTo('onContentFlushed').at(-1)[0], /three/) +}) diff --git a/notes_editor/test/page.mjs b/notes_editor/test/page.mjs new file mode 100644 index 00000000..a7e489c7 --- /dev/null +++ b/notes_editor/test/page.mjs @@ -0,0 +1,126 @@ +// A minimal stand-in for the editor page, enough to run src/custom/runtime.js in Node and check +// its logic: no layout, no real Editor.js. Run the tests with `npm test`. +import fs from 'node:fs' +import vm from 'node:vm' + +const RUNTIME = new URL('../src/custom/runtime.js', import.meta.url) + +function element () { + return { + attrs: {}, + innerText: '', + textContent: '', + listeners: {}, + isContentEditable: false, + setAttribute (k, v) { this.attrs[k] = String(v) }, + removeAttribute (k) { delete this.attrs[k] }, + getAttribute (k) { return this.attrs[k] }, + addEventListener (type, f) { (this.listeners[type] ||= []).push(f) }, + dispatchEvent (event) { (this.listeners[event.type] || []).forEach(f => f(event)) }, + focus () {}, + blur () {}, + contains () { return false }, + getBoundingClientRect () { return { top: 0, bottom: 0 } } + } +} + +/** Loads runtime.js into a fresh page. {@code search} is the page URL's query. */ +export function openPage (search = '') { + const calls = [] + const listeners = {} + const frames = [] + const title = element() + const document = { + documentElement: { scrollHeight: 4000, style: { setProperty () {} } }, + getElementById: id => (id === 'noteTitleInput' ? title : element()), + addEventListener: (type, f) => { (listeners['document:' + type] ||= []).push(f) }, + activeElement: null, + hidden: false, + createRange: () => ({ selectNodeContents () {}, setEnd () {}, toString: () => '' }) + } + const android = new Proxy({}, { get: (_, name) => (...args) => { calls.push([name, ...args]) } }) + const window = { + location: { search }, + scrollY: 0, + innerHeight: 800, + Android: android, + addEventListener: (type, f) => { (listeners['window:' + type] ||= []).push(f) }, + getSelection: () => null, + scrollTo (x, y) { window.scrollY = y }, + scrollBy (x, y) { window.scrollY += y } + } + window.window = window + const context = { + window, + document, + console, + URLSearchParams, + Promise, + setTimeout, + clearTimeout, + Date, + JSON, + requestAnimationFrame: f => frames.push(f), + NodeFilter: { SHOW_TEXT: 4 }, + Event: class { constructor (type) { this.type = type } } + } + vm.createContext(context) + vm.runInContext( + fs.readFileSync(RUNTIME, 'utf8') + + '\n;window.__internals = { get history () { return __hist } }', + context + ) + return { + window, + document, + title, + calls, + /** Sends a DOM event to the listeners the runtime registered on document or window. */ + fire (target, type, event = {}) { + for (const f of listeners[target + ':' + type] || []) f({ type, ...event }) + }, + /** Runs the animation frames requested so far, and those they request. */ + runFrames () { while (frames.length) frames.shift()() }, + callsTo (name) { return calls.filter(c => c[0] === name).map(c => c.slice(1)) } + } +} + +/** + * A stand-in for the Editor.js instance: blocks laid out 100px apart, and save() refusing to work + * while read-only, as Editor.js does. + */ +export function fakeEditor (blocks, { readOnly = false } = {}) { + const state = { readOnly, blocks: blocks.map(b => ({ ...b })) } + const block = i => state.blocks[i] && { + id: state.blocks[i].id, + holder: { + getBoundingClientRect: () => ({ top: 100 * i, bottom: 100 * i + 90 }), + querySelectorAll: () => [] + } + } + const editor = { + readOnly: { + get isEnabled () { return state.readOnly }, + toggle: async value => { state.readOnly = value; return value } + }, + save: () => + state.readOnly + ? Promise.reject(new Error("Editor's content can not be saved in read-only mode")) + : Promise.resolve({ blocks: state.blocks.map(b => ({ ...b })) }), + render: async ({ blocks }) => { state.blocks = blocks.map(b => ({ ...b })) }, + blocks: { + getBlocksCount: () => state.blocks.length, + getBlockByIndex: block, + getById: id => block(state.blocks.findIndex(b => b.id === id)) || null, + update: async (id, data) => { + const target = state.blocks.find(b => b.id === id) + if (target) target.data = data + } + }, + caret: { setToBlock () {}, setToFirstBlock () {} } + } + return { editor, state } +} + +/** Lets pending promises and zero-delay timers run. */ +export const settle = () => new Promise(resolve => setTimeout(resolve, 10)) diff --git a/notes_editor/test/position.test.mjs b/notes_editor/test/position.test.mjs new file mode 100644 index 00000000..abc3a8cb --- /dev/null +++ b/notes_editor/test/position.test.mjs @@ -0,0 +1,57 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { openPage, fakeEditor, settle } from './page.mjs' + +async function shownNote (search, note = {}) { + const page = openPage(search) + const blocks = [0, 1, 2, 3, 4, 5].map(i => ({ id: 'b' + i, type: 'paragraph', data: {} })) + const { editor } = fakeEditor(blocks, { readOnly: search.includes('readonly=1') }) + page.window.attachEditorInstance(editor) + page.window.loadNote({ title: 'T', valueJson: blocks, ...note }) + await settle() + page.runFrames() + return page +} + +test('the position can be read right away once the note is shown, also in reading mode', async () => { + const page = openPage('?readonly=1') + assert.equal(page.window.currentViewStateJson(), null) + + const shown = await shownNote('?readonly=1') + shown.window.scrollY = 250 + const state = JSON.parse(shown.window.currentViewStateJson()) + assert.equal(state.scrollTop, 250) +}) + +test('a long scroll in reading mode is reported while it goes on', async () => { + const page = await shownNote('?readonly=1') + const before = page.callsTo('onViewState').length + // A fling: scroll events for a while, the note is left before they settle. + for (let i = 0; i < 4; i++) { + page.window.scrollY += 100 + page.fire('window', 'scroll') + await new Promise(resolve => setTimeout(resolve, 90)) + } + assert.ok(page.callsTo('onViewState').length > before) +}) + +test('the position is reported as the page is hidden', async () => { + const page = await shownNote('?readonly=1') + const before = page.callsTo('onViewState').length + page.window.scrollY = 120 + page.document.hidden = true + page.fire('document', 'visibilitychange') + assert.equal(page.callsTo('onViewState').length, before + 1) +}) + +test('a note opened at its start shows the title, even if placing the caret scrolled', async () => { + const page = openPage() + const blocks = [{ id: 'a', type: 'paragraph', data: {} }] + const { editor } = fakeEditor(blocks) + editor.caret.setToFirstBlock = () => { page.window.scrollY = 80 } + page.window.attachEditorInstance(editor) + page.window.loadNote({ title: 'T', valueJson: blocks, focusStart: true }) + await settle() + + assert.equal(page.window.scrollY, 0) +})