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',width:{100:'\n\n',80:'\n\n',60:'\n\n',50:'\n\n'},ratio:{null:'\n\n',"16:9":'\n\n',"4:3":'\n\n',"1:1":'\n\n'},align:{left:'\n\n',center:'\n\n',right:'\n\n'},rounded:{0:'\n\n',8:'\n\n',16:'\n\n',24:'\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',width:{100:'\n\n',80:'\n\n',60:'\n\n',50:'\n\n'},ratio:{null:'\n\n',"16:9":'\n\n',"4:3":'\n\n',"1:1":'\n\n'},align:{left:'\n\n',center:'\n\n',right:'\n\n'},rounded:{0:'\n\n',8:'\n\n',16:'\n\n',24:'\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