diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index f20b5d27..d9ff6a1a 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -40,16 +40,16 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Validate Gradle wrapper - uses: gradle/actions/wrapper-validation@748248ddd2a24f49513d8f472f81c3a07d4d50e1 # v4.4.4 + uses: gradle/actions/wrapper-validation@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0 - name: Set up JDK 21 - uses: actions/setup-java@cf277c60eb25467037889841efdb72551f06f6c3 # v4.9.1 + uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1 with: distribution: temurin java-version: '21' - name: Set up Gradle - uses: gradle/actions/setup-gradle@748248ddd2a24f49513d8f472f81c3a07d4d50e1 # v4.4.4 + uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0 - name: Grant execute permission for gradlew run: chmod +x ./gradlew @@ -74,8 +74,16 @@ jobs: - name: Check merged manifest has no INTERNET permission run: | set -euo pipefail + # AGP has used both directory names; a check that finds no manifest must fail, not pass. + manifests=$(find app/build/intermediates/merged_manifest app/build/intermediates/merged_manifests \ + -name AndroidManifest.xml -path '*fdroidRelease*' 2>/dev/null || true) + if [ -z "$manifests" ]; then + echo "::error::No merged fdroidRelease manifest found to check" + exit 1 + fi found=0 - for m in $(find app/build/intermediates/merged_manifests -name AndroidManifest.xml -path '*elease*'); do + for m in $manifests; do + echo "Checking $m" if grep -q 'android.permission.INTERNET' "$m"; then echo "::error file=$m::INTERNET permission found in merged release manifest" found=1 @@ -83,7 +91,7 @@ jobs: done exit $found - # For information only: posts (and keeps updating) one PR comment with the core/security + # For information only: posts (and keeps updating) one PR comment with the app # coverage. It never fails the build. - name: Coverage comment if: github.event_name == 'pull_request' @@ -92,7 +100,7 @@ jobs: with: paths: app/build/reports/kover/reportFdroidDebug.xml token: ${{ secrets.GITHUB_TOKEN }} - title: 'Coverage (core/security)' + title: 'Coverage' update-comment: true min-coverage-overall: 0 min-coverage-changed-files: 0 diff --git a/.github/workflows/publish-play.yml b/.github/workflows/publish-play.yml index 111462c4..14042139 100644 --- a/.github/workflows/publish-play.yml +++ b/.github/workflows/publish-play.yml @@ -61,7 +61,7 @@ jobs: ref: refs/tags/${{ inputs.ref }} - name: Validate Gradle wrapper - uses: gradle/actions/wrapper-validation@748248ddd2a24f49513d8f472f81c3a07d4d50e1 # v4.4.4 + uses: gradle/actions/wrapper-validation@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0 - name: Tag must match version.properties id: version @@ -97,13 +97,13 @@ jobs: exit $missing - name: Set up JDK 21 - uses: actions/setup-java@cf277c60eb25467037889841efdb72551f06f6c3 # v4.9.1 + uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1 with: distribution: temurin java-version: '21' - name: Set up Gradle - uses: gradle/actions/setup-gradle@748248ddd2a24f49513d8f472f81c3a07d4d50e1 # v4.4.4 + uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0 with: cache-read-only: true diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9f6dc2f0..9dffecb4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -40,7 +40,7 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Validate Gradle wrapper - uses: gradle/actions/wrapper-validation@748248ddd2a24f49513d8f472f81c3a07d4d50e1 # v4.4.4 + uses: gradle/actions/wrapper-validation@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0 - name: Tag must match version.properties id: version @@ -71,13 +71,13 @@ jobs: exit $missing - name: Set up JDK 21 - uses: actions/setup-java@cf277c60eb25467037889841efdb72551f06f6c3 # v4.9.1 + uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1 with: distribution: temurin java-version: '21' - name: Set up Gradle - uses: gradle/actions/setup-gradle@748248ddd2a24f49513d8f472f81c3a07d4d50e1 # v4.4.4 + uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0 with: cache-read-only: true diff --git a/CHANGELOG.md b/CHANGELOG.md index 49dff853..80632d8f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,57 @@ IzzyOnDroid) and used as the GitHub Release notes. ## [Unreleased] +## [2.2.0] - 2026-10-15 + +versionCode 20200. + +Notes can be imported from other apps, an optional duress PIN opens a separate decoy vault, and +the editor gets slash commands, smarter paste, find in note and templates. The database and the +backup format do not change. + +### Added + +- Import notes (Settings → Import): bring notes from Google Keep, Standard Notes, Joplin, Notesnook or + Markdown/TXT files, fully offline. The format is recognised from the files, you review and tick the + notes first, and a repeated import adds nothing. Photos and other attachments are left out and + listed without counting toward the size limit, so large Takeouts and Markdown vaults import. +- Reading density for notes (Compact, Default, Comfortable) from the editor's More actions, with a + persistent setting; Default is the existing layout and the stored note never changes. +- Slash commands: type `/` in an empty paragraph to pick a block kind (text, headings, checklist, + lists, quote, link, divider) from a small filtered list. Not available in Simple Edit. +- Smarter paste: pasted text keeps its paragraphs (a blank line starts a new one, a single line + break stays inside it) and Markdown headings, lists, checkboxes, quotes and rules become blocks. + Pasted after existing text, the first line stays plain text. Nothing is rewritten or dropped, + and one undo takes the whole paste back. +- Block actions: duplicate a block from the toolbar, the block sheets and TalkBack, next to move, + delete and convert. +- Find in this note (More actions, or Ctrl/Cmd+F): highlights matches, shows "n of m" and jumps + between them without changing the note. The query is never saved and is cleared when the vault locks. +- Start from template: an optional Meeting, Journal, Project notes or Checklist outline for a new, + empty note. Blank stays the default. +- Optional 15- and 30-minute auto-lock delays, with a warning about longer background sessions. +- Duress PIN (Settings → Security): a third PIN that opens a separate, fully usable decoy vault + with its own encryption key and database, from the same lock screen and with the same unlock. + The real vault stays closed and unchanged, and nothing in the decoy vault leads back to it. + Unlike the wipe PIN it erases nothing, and typing it between wrong guesses never lifts the + limit on guesses at the real PIN. It does not hide the real vault from a forensic + examination (see SECURITY.md). + +### Changed + +- Pasting several lines of plain text keeps paragraphs (a blank line starts a new block) instead + of creating one block per line. +- The predictive back gesture plays the same sliding motion as the back button instead of + shrinking the screen. +- Dependencies updated to their latest stable versions, Compose through its BOM; the app now + compiles against SDK 37 (minimum and target SDK unchanged). + +### ⚠️ Compatibility + +- Going back to 2.1.0 keeps the vault and its backups readable: neither the database nor the + backup format changes. The duress PIN only works in 2.2.0 and later, and changing the PIN in an + older version turns it off (see SECURITY.md → Duress PIN). + ## [2.1.0] - 2026-10-05 versionCode 20100. @@ -194,7 +245,8 @@ The last release of the old storage format (versionCode 30): the SQLCipher key w Keystore-sealed seed hash, with an optional 4-digit PIN. It had no public users; superseded by 2.0.0. -[Unreleased]: https://github.com/pasichDev/Encly/compare/v2.1.0...HEAD +[Unreleased]: https://github.com/pasichDev/Encly/compare/v2.2.0...HEAD +[2.2.0]: https://github.com/pasichDev/Encly/releases/tag/v2.2.0 [2.1.0]: https://github.com/pasichDev/Encly/releases/tag/v2.1.0 [2.0.1]: https://github.com/pasichDev/Encly/releases/tag/v2.0.1 [2.0.0]: https://github.com/pasichDev/Encly/releases/tag/v2.0.0 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8c9b0c4a..cde2db64 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -40,8 +40,9 @@ Run the same gates as CI: ./gradlew assembleRelease # R8 build ``` -- Add or extend unit tests for behaviour you change. Code in `core/security` has a minimum line - coverage gate (Kover, see `app/build.gradle.kts`); do not lower it to get a PR through. +- Add or extend unit tests for behaviour you change. The app has a minimum line coverage gate of + 85% (Kover, generated code excluded, see `app/build.gradle.kts`); do not lower it to get a PR + through. - For storage, Keystore or SQLCipher changes also run the instrumented tests on an emulator: `./gradlew :app:connectedFdroidDebugAndroidTest` (they wipe the debug app's data). - New detekt or lint findings must be fixed, not added to `app/detekt-baseline.xml` or @@ -104,8 +105,8 @@ purpose. Every user-visible string lives in string resources. English is the default (`app/src/main/res/values/`); each translation is a `values-/` folder with a `strings.xml` -(most of the UI), a `strings_security.xml` (security settings) and a `strings_handoff.xml` (the -import from My Notes). +(most of the UI), a `strings_security.xml` (security settings), a `strings_handoff.xml` (the +import from My Notes) and a `strings_import.xml` (Import notes from other apps). **Translating on Weblate (recommended):** the project is on [Hosted Weblate](https://hosted.weblate.org/projects/encly/), one component per file (*App*, @@ -116,8 +117,8 @@ it never pushes to `main`. That pull request runs the same CI as any other, so i when every check below passes. **Fixing a translation in Git:** edit the string in `values-/strings.xml` (or -`strings_security.xml`, `strings_handoff.xml`) and open a PR. Keep the key; only change the -text. If a Weblate pull request is open, say so in yours; Weblate picks up merged changes from +`strings_security.xml`, `strings_handoff.xml`, `strings_import.xml`) and open a PR. Keep the key; +only change the text. If a Weblate pull request is open, say so in yours; Weblate picks up merged changes from `main` on its own. **For maintainers:** Weblate pulls `main` on every push (GitHub webhook @@ -138,8 +139,8 @@ lower. **Adding a language:** -1. start it on Weblate, or copy `values/strings.xml`, `values/strings_security.xml` and - `values/strings_handoff.xml` to `values-/` and translate them, +1. start it on Weblate, or copy `values/strings.xml`, `values/strings_security.xml`, + `values/strings_handoff.xml` and `values/strings_import.xml` to `values-/` and translate them, leaving out the entries marked `translatable="false"`; 2. add `` to `res/xml/locales_config.xml`; 3. add an entry to `AppLanguage` (`core/locale/AppLanguage.kt`) and its own-language name as a diff --git a/PRIVACY.md b/PRIVACY.md index 0a1c74e6..148d6944 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -93,6 +93,18 @@ Encly makes while reading it is deleted right away (or, if Encly is closed in th time it starts); the imported notes are stored encrypted like everything else. Nothing is sent over the network, and Encly sends nothing back to My Notes except how many items were imported. Attachments and images stay in My Notes. +## Import notes from other apps + +**Settings → Import notes** reads export files of Google Keep (Takeout), Standard Notes +(decrypted backup), Joplin (JEX or RAW), Notesnook (Markdown or plain-text export) and plain +Markdown/text notes such as an Obsidian vault. You choose the files or folder in the system file +picker; Encly reads only what you picked, only while the app is unlocked, and only on this +phone. Nothing is uploaded, no account is needed, and no network is used. The files are read +into memory, never copied to disk, and the files themselves are left unchanged. You see the list +of notes before anything is saved, pick which ones to import, and the selected notes are stored +encrypted in the vault that is open. Encrypted exports of those apps are not read; export them +in a readable format first. + ## Deleting your data Uninstalling Encly, or clearing its storage in Android settings, permanently deletes all of diff --git a/README.md b/README.md index c343218c..5bcf468d 100644 --- a/README.md +++ b/README.md @@ -85,7 +85,7 @@ Every release ships a `SHA256SUMS` file (and `SHA256SUMS.asc` when it is GPG-sig ```bash sha256sum -c --ignore-missing SHA256SUMS # APK matches the published checksum -apksigner verify --print-certs Encly-2.1.0-fdroid.apk +apksigner verify --print-certs Encly-2.2.0-fdroid.apk ``` `apksigner` (Android SDK build-tools) must print this release signing certificate SHA-256 @@ -181,7 +181,7 @@ links, no baseline profile) and `play`. ```bash ./gradlew assembleFdroidDebug # debug APK (com.pasich.encly.debug) ./gradlew :app:testFdroidDebugUnitTest # JVM unit tests -./gradlew :app:koverVerifyFdroidDebug # coverage gate for core/security +./gradlew :app:koverVerifyFdroidDebug # coverage gate (whole app, 85%) ./gradlew :app:detekt :app:lintFdroidRelease # static analysis ./gradlew spotlessApply # format Kotlin/Gradle (ktlint; spotlessCheck in CI) ./gradlew assembleRelease # both release APKs, R8 enabled diff --git a/SECURITY.md b/SECURITY.md index 7164a6d8..82babb4d 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -18,7 +18,11 @@ DEK (random 256 bit) ── SQLCipher raw key ├─ PIN slot AES-GCM(kek_pin) kek_pin = HKDF(PBKDF2(pin, salt, 600k) ‖ HMAC_keystore(salt ‖ pin)) ├─ biometric slot AES-GCM(keystore key) auth-per-use, BIOMETRIC_STRONG CryptoObject ├─ recovery slot AES-GCM(kek_rec) kek_rec = HKDF(SHA-256(normalized 12 words), "encly/recovery/kek/v3") -└─ backup-key slot AES-GCM(HKDF(DEK)) holds backupRoot = HKDF(SHA-256(words), "encly/backup/root/v1") +├─ backup-key slot AES-GCM(HKDF(DEK)) holds backupRoot = HKDF(SHA-256(words), "encly/backup/root/v1") +└─ decoy key AES-GCM(HKDF(DEK)) holds decoy DEK (see "Duress PIN") + +decoy DEK (random 256 bit) ── SQLCipher raw key of the decoy vault (vault_b.db) +└─ duress slot AES-GCM(kek_duress) kek_duress = HKDF(same PIN KDF run, "encly/pin/decoy/v3") ``` ### PIN slot @@ -46,7 +50,9 @@ DEK (random 256 bit) ── SQLCipher raw key and doubles with each further miss (1, 2, 4 … 32 min, ~1 h …) up to 24 h. It runs on `SystemClock.elapsedRealtime`, so changing the wall clock does not end it; across a reboot the remaining penalty is kept and restarts from boot (a reboot never shortens a lockout). A - failure of the Keystore itself is not counted as a guess. Wrong PINs never erase anything: + failure of the Keystore itself is not counted as a guess. A second counter (`pin.strikes`) + keeps the misses since the PIN itself was last right; see the duress PIN below for why. Wrong + PINs never erase anything: the only wipe is the optional **wipe PIN** below, and only when the user set one up. - Changing the PIN keeps the slot's salt (slots written before keep their own), so a wipe PIN set earlier keeps working. @@ -66,7 +72,9 @@ and show me"), not against a forensic examination. wipe PIN it holds random bytes, written by a startup migration that needs no PIN and leaves `pin.slot` byte for byte as it was. The store has the same keys and sizes either way; no verifier, hash or extra Keystore key exists for it. Only the wipe PIN itself can tell, so - Settings never shows whether one is set. + Settings never shows whether one is set. Setting another PIN (wipe, duress or a new PIN) in + Settings is refused when it is one of the others, which tells; each such refusal therefore + costs a miss like a wrong guess, and the wipe and duress PIN cannot be set during a lockout. - **Same cost either way.** Each attempt runs PBKDF2 and the Keystore HMAC once, derives both KEKs and always tries both AES-GCM opens, without early exit. - **Rules.** 6 digits and different from the PIN (checked by opening the PIN slot with it). @@ -137,6 +145,77 @@ and show me"), not against a forensic examination. same release moves the database to version 4, which older builds cannot open at all (see CHANGELOG). +### Duress PIN + +An optional third PIN (Settings → Security → Duress PIN; set, change or turn off, each after +the real PIN). Typed on the lock screen, it opens a **decoy vault**: a second, fully usable +vault with its own random DEK and its own SQLCipher database (`vault_b.db`, with its `-wal` and +`-shm`), empty the first time. Its notes, tags and tasks persist across re-locks and restarts. +The real vault stays closed and is not changed. Unlike the wipe PIN, it erases nothing. It is +meant for "open the app and show me", not against a forensic examination. + +- **Key material.** The duress slot (`pin.decoy.slot`) is + `version ‖ IV ‖ AES-GCM_dkek(decoy DEK)`, `dkek = HKDF(sw ‖ hw, salt, info = + "encly/pin/decoy/v3")` from the same KDF run as the PIN KEK, its AAD + (`"encly/pin/decoy-slot/v3"`) bound to the PIN slot's salt, like the wipe slot. The real vault + also keeps the decoy DEK in `decoy.key`, sealed under `HKDF(DEK, "encly/decoy/key/salt/v1", + "encly/decoy/key/kek/v1")`, so changing the duress PIN keeps the decoy notes and only the real + vault's session can tell whether a decoy vault exists. The decoy DEK never wraps or unwraps + anything of the real vault, and nothing derived from it opens a real-vault slot. +- **Nothing in the store shows it is set.** Both entries always exist with the same length: a + startup migration that needs no PIN writes random bytes into them and leaves `pin.slot` byte + for byte as it was; turning the duress PIN off writes random bytes again, and every step that + replaces one of them (a new PIN slot, the erase) replaces it with or without a decoy vault. + Whether a dead duress slot needs setting again is kept inside `decoy.key` (sealed with another + AAD, `"encly/decoy/key/off/v1"`), not in a store entry of its own. The lockout counters are the + exception, see below. +- **One attempt, three slots.** Every lock-screen attempt is counted before the KDF, runs + PBKDF2 and the Keystore HMAC once, derives the three KEKs and always tries all three AES-GCM + opens, without early exit. The PIN opens the real vault and the duress PIN the decoy one with + the same result, animation and screen; the wipe PIN erases. Settings never lets one PIN open + two slots; should it happen anyway, the order is PIN, duress, wipe (never the erase while a + PIN could open a vault). The duress PIN is refused during a lockout and ends it like the + PIN, but not the strikes (misses since the PIN itself was last right): from the 10th strike + on every miss is locked out, escalating as above, even right after the duress PIN opened the + decoy vault. Without that, someone who was given the duress PIN could guess at the real + PIN 4 times, type the duress PIN to reset the lockout, and repeat without limit. The cost is a + tell after that many misses (a lockout sooner than the screen suggests), and a store-level + one: lockout failures at 0 while `pin.strikes` is not shows a non-PIN unlock since the misses. +- **Rules.** 6 digits, different from the PIN and from the wipe PIN (checked by opening their + slots with it); a wipe PIN equal to the duress PIN is refused, and so is changing the PIN to + the duress PIN, since either would silently orphan the decoy notes. A reset of the PIN's + Keystore key, or a PIN set with a new salt (after a recovery-phrase unlock when the PIN slot + could not be read), turns the duress PIN off like the wipe PIN; Settings in the real vault + then shows it as off and says why, and setting it again opens the same decoy notes. A refused + collision costs a miss, as for the wipe PIN. +- **Inside the decoy vault.** Notes, tags, tasks, trash, search, sorting and appearance work as + usual. Settings shows the same entries as in the real vault, and everything that belongs + to the real vault is refused by the security layer and fails like a storage error would, with + the usual generic message: changing the PIN, the wipe and duress PINs, fingerprint unlock, the + recovery phrase, encrypted backups (export and import) and "Erase all data". A PIN re-check + there accepts the duress PIN only, so the decoy vault itself cannot be used to test guesses + of the real PIN; on the lock screen the strikes above bound them. Fingerprint unlock and the recovery phrase on the lock screen always open the real + vault, and nothing in the decoy vault offers a way to switch: to change vaults, lock and type + the other PIN. App settings (theme, sorting, font, auto-lock, keyboard privacy) are shared. +- **Session isolation.** Only one database is open at a time. Every repository is bound to the + opening it was created in (a lease revoked before Room closes), so nothing created in one + vault reads or writes the other. Opening the other vault starts a new epoch, so the note that + was open when the app re-locked is reopened only in the vault it belongs to. +- **Failures never fall back.** A decoy database that does not open fails the unlock; it is + neither deleted nor replaced by the real vault. Turning the duress PIN off (after the PIN and a + confirmation) forgets the decoy key first and then deletes `vault_b.db`; a kill in between + leaves a file nothing can open, which the next duress PIN set replaces. +- **Wipe PIN.** The erase also replaces both decoy entries with random bytes and deletes + `vault_b.db` with the real database: the empty vault has no decoy vault either. +- **Limits.** The decoy vault does not hide that the real one exists from a forensic look: the + second database file, its size and timestamps, filesystem leftovers, or comparing two + snapshots. The first unlock with the duress PIN creates that database and takes longer. + Biometrics can be forced. On a rooted, unlocked phone PIN guessing through the Keystore (see + "Important limitations") finds the duress PIN as well. +- **Downgrade.** Older builds ignore both entries and the second database and keep opening the + real vault. A PIN changed in an older build gets a new salt, which silently turns the duress + PIN off; the decoy notes come back once it is set again in this version. + ### Biometric slot Biometric unlock is optional. @@ -311,6 +390,47 @@ counts, and exposes no provider or anything readable. the backup merge import: one transaction, records whose uid exists are skipped (a repeated hand-off adds nothing), tags are matched by name. +## Import from other apps + +Settings → Import notes reads exports of other notes apps (pasichDev/Encly#62): Google Keep +Takeout, a decrypted Standard Notes backup, Joplin JEX or RAW, Notesnook Markdown/TXT exports and +generic Markdown/TXT (Obsidian, Markor). + +- **Input.** Only documents and folders the user picks through the Storage Access Framework, + read as streams through `ContentResolver` while the vault is unlocked (the picker gets the + same short grace as a backup import). No path, seek or declared size is trusted. The source + files are never written. +- **Bounded reading, in memory only.** ZIP is read sequentially from local headers with a reader + of Encly's own (every entry's CRC checked; encrypted entries refused), TAR with a minimal + ustar/GNU reader that hands data out only up to the size of each entry. Limits, counted on the + bytes actually read and inflated: 2 GiB read, 64 MiB uncompressed for what is kept, 4 MiB per + file (16 MiB for a JSON export that holds every note), 20 000 entries (directories and every + archive header included), 20 000 notes, path depth 16, JSON nesting 64. Media and attachments + (images, audio, video, PDFs, by name) are counted and never kept: a picked file or folder entry + is not opened and a TAR member is skipped unread; a ZIP member has to be inflated to find the + next header, so it is streamed through a small buffer against its own 2 GiB cap, and a bomb + named `photo.jpg` still ends as "too large". Absolute paths, `..`, drive letters, NUL, links, + devices, archives inside archives and folders deeper than the limit are skipped and counted + (a backslash is read as `/`); a ZIP bomb or an endless archive ends as "too large", and a + damaged archive keeps the notes read before the damage and counts the rest as unreadable. + Nothing is staged in `cacheDir` or anywhere else on disk, and nothing is logged. +- **Formats are recognised by content, not by extension;** an ambiguous selection asks the user. + Encrypted Standard Notes backups and Notesnook backups (`.nnbackupz`) are refused with guidance + instead of being parsed. Markdown and HTML become plain Encly blocks (inline HTML tags are + removed, other angle-bracket text stays); nothing is executed or rendered as HTML, and + frontmatter is read for a few known keys only. +- **Review before writing.** Parsed notes (titles, a snippet, the read-only text) are shown on a + review screen and live only in that screen's ViewModel: no saved state, nothing persisted, and + they are dropped on cancel, on finish and when the vault locks. The selected notes go through + the backup merge import: one transaction on the database session the import started in, all + or nothing, notes whose uid exists are skipped (the uid is derived from the source's own id, or for Markdown from the path + and a digest of the text, so a repeated import adds nothing), tags matched by name. +- **Vaults.** It is available in a decoy session too and imports into whichever vault is open; + a vault switch or re-lock during the import makes the transaction fail instead of writing + elsewhere. +- **Limits.** Like the editor and the backup import, parsed text passes through JVM strings that + cannot be zeroized. + ## Session lifecycle - The database is not considered committed until mandatory PIN setup succeeds. @@ -384,7 +504,7 @@ The app requests no `INTERNET` permission and performs no analytics or sync. - Android app sandbox isolates local files from ordinary apps. - `allowBackup=false` is set. - Database/security state is excluded from cloud backup and device transfer. -- The key slots (PIN, wipe-PIN, recovery, backup-key and biometric envelopes) and the PIN +- The key slots (PIN, wipe-PIN, duress-PIN, decoy-key, recovery, backup-key and biometric envelopes) and the PIN lockout state live in one app-private file, `no_backup/vault_state_v3.bin`. Every secret in it is already an AES-256-GCM envelope, so it needs no further encryption layer and **no Keystore key to be read**: a broken Keystore or Tink keyset can no longer make the vault state unreadable at @@ -444,8 +564,8 @@ The app requests no `INTERNET` permission and performs no analytics or sync. missing the key lives in the TEE; on devices without hardware-backed Keystore it is software only. - The lockout is enforced by the app, not by the secure hardware. -- The wipe PIN (see "Wipe PIN") defends against being forced to unlock on the spot, not against - a forensic examination or a rooted phone. +- The wipe PIN (see "Wipe PIN") and the duress PIN (see "Duress PIN") defend against being + forced to unlock on the spot, not against a forensic examination or a rooted phone. - The recovery seed restores the local vault (recovery slot) and opens encrypted backups. On a new phone the seed alone recreates nothing: the user also needs a backup file, and only data up to that export is restored. diff --git a/app/build.gradle.kts b/app/build.gradle.kts index c8799e77..1908888f 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,7 +1,7 @@ import java.util.Properties -// Minimum line coverage (%) of com.pasich.encly.core.security enforced by koverVerify. -val koverMinSecurityLineCoverage = 40 +// Minimum line coverage (%) of the whole app (generated code excluded) enforced by koverVerify. +val koverMinLineCoverage = 85 // Derive the app version from the tracked root `version.properties` so versionName and // versionCode always stay in sync and only one file needs bumping per release. @@ -39,7 +39,6 @@ plugins { alias(libs.plugins.android.application) alias(libs.plugins.hilt) alias(libs.plugins.ksp) - alias(libs.plugins.kotlin.android) alias(libs.plugins.kotlin.compose) alias(libs.plugins.kotlin.serialization) alias(libs.plugins.detekt) @@ -57,7 +56,7 @@ detekt { } android { - compileSdk = 36 + compileSdk = 37 namespace = "com.pasich.encly" defaultConfig { @@ -252,13 +251,29 @@ kover { reports { variant("fdroidDebug") { filters { - includes { - packages("com.pasich.encly.core.security") + // Code the compilers and annotation processors write, not this project. + excludes { + classes( + "*.BuildConfig", + "*.R", + "*.R$*", + "*ComposableSingletons*", + "*Hilt_*", + "*_HiltModules*", + "*_Factory", + "*_Factory$*", + "*_MembersInjector", + "*_GeneratedInjector", + "*_Impl", + "*_Impl$*", + "*Dagger*", + ) + packages("dagger.hilt", "hilt_aggregated_deps") } } verify { - rule("core/security line coverage") { - minBound(koverMinSecurityLineCoverage) + rule("App line coverage") { + minBound(koverMinLineCoverage) } } } @@ -267,7 +282,12 @@ kover { dependencies { - // Jetpack Compose + // Jetpack Compose: every androidx.compose module takes its version from the BOM. + val composeBom = platform(libs.androidx.compose.bom) + implementation(composeBom) + debugImplementation(composeBom) + testImplementation(composeBom) + androidTestImplementation(composeBom) implementation(libs.androidx.compose.ui) implementation(libs.androidx.compose.ui.tooling.preview) implementation(libs.androidx.lifecycle.runtime.ktx) @@ -314,8 +334,6 @@ dependencies { implementation(libs.androidx.sqlite.ktx) implementation(libs.kotlin.bip39) - // Security Crypto (see the note in gradle/libs.versions.toml before replacing it) - implementation(libs.icons.lucide) // detekt rule set for Jetpack Compose (Apache-2.0); runs only inside the detekt task. diff --git a/app/src/androidTest/java/com/pasich/encly/data/database/SecureDatabaseManagerInstrumentedTest.kt b/app/src/androidTest/java/com/pasich/encly/data/database/SecureDatabaseManagerInstrumentedTest.kt index 9d8c309c..44fab51f 100644 --- a/app/src/androidTest/java/com/pasich/encly/data/database/SecureDatabaseManagerInstrumentedTest.kt +++ b/app/src/androidTest/java/com/pasich/encly/data/database/SecureDatabaseManagerInstrumentedTest.kt @@ -49,7 +49,9 @@ class SecureDatabaseManagerInstrumentedTest { // passphrase buffer the open-helper factory keeps. A zeroed buffer fails here with // "file is not a database". val counts = (1..PARALLEL_READERS).map { - async(Dispatchers.IO) { manager.getDatabase().notesDao().getAllNotes().first().size } + async(Dispatchers.IO) { + manager.getDatabase(manager.captureLease()!!).notesDao().getAllNotes().first().size + } }.awaitAll() assertTrue(counts.all { it == NOTE_COUNT }) @@ -58,13 +60,16 @@ class SecureDatabaseManagerInstrumentedTest { @Test fun lockThenUnlockAgainReopensTheSameData() = runBlocking { assertTrue(manager.unlockDatabase(KEY_A, allowCreate = true)) + val oldLease = requireNotNull(manager.captureLease()) insertNotes(NOTE_COUNT) manager.reset() assertFalse(manager.isDatabaseUnlocked()) assertTrue(manager.unlockDatabase(KEY_A)) + assertTrue(runCatching { manager.getDatabase(oldLease) }.isFailure) + assertTrue(manager.getDatabase(requireNotNull(manager.captureLease())).isOpen) - assertEquals(NOTE_COUNT, manager.getDatabase().notesDao().getAllNotes().first().size) + assertEquals(NOTE_COUNT, manager.getDatabase(manager.captureLease()!!).notesDao().getAllNotes().first().size) } @Test @@ -76,8 +81,21 @@ class SecureDatabaseManagerInstrumentedTest { assertTrue(manager.hasEncryptedDatabase()) } + @Test + fun aDaoFromTheClosedOpeningCannotWriteAfterReopen() = runBlocking { + assertTrue(manager.unlockDatabase(KEY_A, allowCreate = true)) + val oldDao = manager.getDatabase(requireNotNull(manager.captureLease())).notesDao() + + manager.reset() + assertTrue(manager.unlockDatabase(KEY_A)) + + val staleWrite = runCatching { oldDao.insertNote(Note(title = "stale", value = "old session")) } + assertTrue(staleWrite.isFailure) + assertTrue(manager.getDatabase(manager.captureLease()!!).notesDao().getAllNotes().first().isEmpty()) + } + private suspend fun insertNotes(count: Int) { - val dao = manager.getDatabase().notesDao() + val dao = manager.getDatabase(manager.captureLease()!!).notesDao() repeat(count) { dao.insertNote(Note(title = "note $it", value = "body $it")) } } diff --git a/app/src/main/java/com/pasich/encly/core/security/AuthenticationManager.kt b/app/src/main/java/com/pasich/encly/core/security/AuthenticationManager.kt index 67de1cbb..3aba81ae 100644 --- a/app/src/main/java/com/pasich/encly/core/security/AuthenticationManager.kt +++ b/app/src/main/java/com/pasich/encly/core/security/AuthenticationManager.kt @@ -1,12 +1,25 @@ package com.pasich.encly.core.security +import com.pasich.encly.core.security.DuressSlots.DECOY_KEY_AAD +import com.pasich.encly.core.security.DuressSlots.DECOY_KEY_OFF_AAD +import com.pasich.encly.core.security.DuressSlots.decoyKeyKek +import com.pasich.encly.core.security.DuressSlots.decoySlotAad +import com.pasich.encly.core.security.DuressSlots.openDecoy +import com.pasich.encly.core.security.DuressSlots.randomSlot +import com.pasich.encly.core.security.DuressSlots.sealDecoy +import com.pasich.encly.core.security.PinSlots.DEK_LENGTH +import com.pasich.encly.core.security.PinSlots.PIN_SALT_SIZE +import com.pasich.encly.core.security.PinSlots.ParsedSlot +import com.pasich.encly.core.security.PinSlots.decoyWipeSlot +import com.pasich.encly.core.security.PinSlots.openSlotOrNull +import com.pasich.encly.core.security.PinSlots.opensWipeSlot +import com.pasich.encly.core.security.PinSlots.parseSlot +import com.pasich.encly.core.security.PinSlots.sealSlot +import com.pasich.encly.core.security.PinSlots.sealWipeSlot import java.security.GeneralSecurityException import java.security.SecureRandom -import javax.crypto.Cipher import javax.crypto.SecretKeyFactory -import javax.crypto.spec.GCMParameterSpec import javax.crypto.spec.PBEKeySpec -import javax.crypto.spec.SecretKeySpec import javax.inject.Inject import javax.inject.Singleton @@ -39,6 +52,12 @@ sealed interface PinUnlock { */ class Erased(val dek: ByteArray) : PinUnlock + /** + * The duress PIN was typed on the lock screen: [dek] opens the decoy vault, a database of + * its own. The caller owns [dek] and must wipe it. Nothing of the vault was changed. + */ + class Decoy(val dek: ByteArray) : PinUnlock + /** A wrong (or malformed) PIN; the attempt was counted. */ data object WrongPin : PinUnlock @@ -61,6 +80,34 @@ enum class WipePinChange { /** The wipe PIN is the vault's PIN: it would never wipe anything. */ SAME_AS_PIN, + + /** The wipe PIN is the duress PIN: one PIN never opens two slots. */ + IN_USE, + FAILED, +} + +/** The outcome of setting the vault's PIN from Settings. */ +enum class PinChange { + SET, + + /** The new PIN is the duress PIN: one PIN never opens two slots. */ + IN_USE, + FAILED, +} + +/** The outcome of setting the duress PIN. */ +enum class DuressPinChange { + /** Set; the decoy vault it opens keeps the notes it had. */ + SET, + + /** Set for a new, empty decoy vault (there was none, or it was removed). */ + SET_NEW_VAULT, + + /** The duress PIN is the vault's PIN. */ + SAME_AS_PIN, + + /** The duress PIN is the wipe PIN. */ + IN_USE, FAILED, } @@ -118,9 +165,16 @@ class AuthenticationManager @Inject constructor( * PIN key had to be reset or was gone and had to be made anew (Settings then says so), or * must not, because [pin] is the wipe PIN itself: one PIN never opens both slots. */ - @Suppress("ReturnCount") // Validation gates fail closed before any key is touched. - fun configurePin(pin: CharArray, dek: ByteArray): Boolean { - if (!isWellFormed(pin) || dek.size != DEK_LENGTH) return false + fun configurePin(pin: CharArray, dek: ByteArray): Boolean = changePin(pin, dek) == PinChange.SET + + /** + * [configurePin], telling why it was refused. The duress slot is handled like the wipe slot, + * except that a new PIN equal to the duress PIN is refused ([PinChange.IN_USE]) rather than + * turning it off: the decoy vault holds notes, which a PIN change must not silently orphan. + */ + @Suppress("ReturnCount", "CyclomaticComplexMethod") // Validation gates fail closed before any key is touched. + fun changePin(pin: CharArray, dek: ByteArray): PinChange = synchronized(attemptLock) { + if (!isWellFormed(pin) || dek.size != DEK_LENGTH) return PinChange.FAILED val keySlot = activeKeySlot() val keptSalt = currentSalt() val salt = keptSalt ?: ByteArray(PIN_SALT_SIZE).also { SecureRandom().nextBytes(it) } @@ -140,17 +194,28 @@ class AuthenticationManager @Inject constructor( } } catch (_: PinFactorException) { SensitiveDataCleaner.clear(salt) - return false + return PinChange.FAILED } return try { + // Same key and salt: a duress slot this PIN opens would make it open two vaults. + if (!keyReset && keptSalt != null && opensDecoySlot(keys.decoyKek, salt)) { + return if (chargeMiss()) PinChange.IN_USE else PinChange.FAILED + } val change = when { keyReset && keptSalt != null -> WipeSlotChange.TURN_OFF_AND_TELL keyReset || keptSalt == null -> WipeSlotChange.TURN_OFF else -> wipeSlotChangeFor(keys.wipeKek, salt) } - writePinSlot(dek, keys.kek, salt, keySlot, change) + // The duress slot survives only what the wipe slot survives: same salt, same key. + val decoyChange = when { + keyReset && keptSalt != null -> WipeSlotChange.TURN_OFF_AND_TELL + keyReset || keptSalt == null -> WipeSlotChange.TURN_OFF + else -> WipeSlotChange.KEEP + } + val written = writePinSlot(dek, keys.kek, salt, keySlot, SlotChanges(change, decoyChange)) + if (written) PinChange.SET else PinChange.FAILED } catch (_: GeneralSecurityException) { - false + PinChange.FAILED } finally { SensitiveDataCleaner.clear(salt) keys.wipe() @@ -161,10 +226,13 @@ class AuthenticationManager @Inject constructor( * One PIN attempt. [pin] stays the caller's to wipe. On [PinUnlock.Success] and * [PinUnlock.Erased] the caller owns the DEK and must wipe it. * - * The KDF runs once and both slots are always tried, so the PIN, the wipe PIN and a wrong - * PIN cost the same up to here. The wipe PIN is counted before the KDF like any attempt and - * is refused during a lockout; once it matched, the erase drops the lockout like a right - * PIN does. In a PIN check inside the open vault ([verifyPinAuth]) it is a wrong PIN. + * The KDF runs once and all three slots (PIN, wipe, duress) are always tried, so the PIN, + * the wipe PIN, the duress PIN and a wrong PIN cost the same up to here. The duress PIN + * returns [PinUnlock.Decoy] and changes nothing but the lockout, which it ends like the + * PIN; the misses since the PIN was last right stay counted (see [penaltyFor]). The wipe + * PIN is counted before the KDF like any attempt and is refused during a lockout; once it + * matched, the erase drops the lockout like a right PIN does. In a PIN check inside the + * open vault ([verifyPinAuth]) it is a wrong PIN. * * On the wipe PIN, before returning: phase 1 makes a new key in the other Keystore slot * ([PinKeySlot]), a new DEK, a new PIN slot that the wipe PIN opens (same salt, and the @@ -172,27 +240,40 @@ class AuthenticationManager @Inject constructor( * wipe slot; phase 2 swaps them in with one atomic store edit that also drops the recovery, * backup-key and biometric slots and the lockout, and records [WipeStage.DATABASE]. */ - fun unlockWithPin(pin: CharArray): PinUnlock = attempt(pin, allowWipe = true) + fun unlockWithPin(pin: CharArray): PinUnlock = attempt(pin, Purpose.UNLOCK) /** - * Whether [pin] opens the slot; counts like an unlock attempt. [pin] stays the caller's. - * The wipe PIN only wipes from the lock screen: here it is a wrong PIN. + * Whether [pin] is the PIN of the open session's vault; counts like an unlock attempt. [pin] + * stays the caller's. With [decoy] (a session the duress PIN opened) only the duress PIN + * matches, so a decoy session can not be used to test guesses of the real PIN. The wipe PIN + * only wipes from the lock screen: here it is a wrong PIN. */ - fun verifyPinAuth(pin: CharArray): Boolean { - val result = attempt(pin, allowWipe = false) - if (result is PinUnlock.Success) SensitiveDataCleaner.clear(result.dek) - return result is PinUnlock.Success + fun verifyPinAuth(pin: CharArray, decoy: Boolean = false): Boolean { + val result = attempt(pin, if (decoy) Purpose.VERIFY_DECOY else Purpose.VERIFY) + return when (result) { + is PinUnlock.Success -> { + SensitiveDataCleaner.clear(result.dek) + true + } + + is PinUnlock.Decoy -> { + SensitiveDataCleaner.clear(result.dek) + true + } + + else -> false + } } - /** [unlockWithPin]; with [allowWipe] false a wipe-PIN match is a wrong PIN. */ + /** One counted attempt; see [unlockWithPin] and [verifyPinAuth]. */ @Suppress("ReturnCount") // Fail-closed gates: lockout, missing slot, unrecorded attempt. - private fun attempt(pin: CharArray, allowWipe: Boolean): PinUnlock = synchronized(attemptLock) { + private fun attempt(pin: CharArray, purpose: Purpose): PinUnlock = synchronized(attemptLock) { if (remainingLockoutMillis() > 0) return PinUnlock.LockedOut val slot = store.getBytes(PIN_SLOT_KEY) ?: return PinUnlock.WrongPin try { - val previousFailures = store.getInt(FAILURES_KEY, 0) + val previous = Misses(store.getInt(FAILURES_KEY, 0), store.getInt(STRIKES_KEY, 0)) // Counted before the KDF: an attempt the app is killed during still counts. - if (!chargeAttempt(previousFailures + 1)) return PinUnlock.Failed + if (!chargeAttempt(previous.next())) return PinUnlock.Failed if (!isWellFormed(pin)) return PinUnlock.WrongPin val parsed = parseSlot(slot) ?: return PinUnlock.WrongPin @@ -201,11 +282,11 @@ class AuthenticationManager @Inject constructor( derivePinKeys(pin, parsed.salt, activeKeySlot()) } catch (e: PinFactorException) { // Not a guess: the hardware never answered. Give the attempt back. - refundAttempt(previousFailures) + refundAttempt(previous) return if (e.lost) PinUnlock.KeyLost else PinUnlock.Failed } try { - checkBothSlots(pin, parsed, keys, allowWipe) + checkSlots(pin, parsed, keys, Check(purpose, previous.strikes)) } finally { keys.wipe() } @@ -221,11 +302,12 @@ class AuthenticationManager @Inject constructor( * Makes [wipePin] the wipe PIN, replacing any earlier one (whether there was one cannot be * known: a real wipe slot and the decoy look the same). [wipePin] stays the caller's to * wipe. Refused when it is the PIN itself. Runs the KDF once, like an unlock, and is not - * counted as an attempt: the caller has the vault open and has checked the PIN. + * counted as an attempt (the caller has the vault open and has checked the PIN), except + * when refused for being another PIN; refused outright during a lockout. */ @Suppress("ReturnCount") // Fail-closed gates before the slot is written. fun configureWipePin(wipePin: CharArray): WipePinChange = synchronized(attemptLock) { - if (!isWellFormed(wipePin)) return WipePinChange.FAILED + if (!isWellFormed(wipePin) || remainingLockoutMillis() > 0) return WipePinChange.FAILED val slot = store.getBytes(PIN_SLOT_KEY) ?: return WipePinChange.FAILED val parsed = try { parseSlot(slot) @@ -243,7 +325,11 @@ class AuthenticationManager @Inject constructor( val dek = openSlotOrNull(parsed, keys.kek) if (dek != null) { SensitiveDataCleaner.clear(dek) - return WipePinChange.SAME_AS_PIN + return if (chargeMiss()) WipePinChange.SAME_AS_PIN else WipePinChange.FAILED + } + // Nor may it be the duress PIN, which opens the decoy vault first. + if (opensDecoySlot(keys.decoyKek, parsed.salt)) { + return if (chargeMiss()) WipePinChange.IN_USE else WipePinChange.FAILED } writeWipeSlot(keys.wipeKek, parsed.salt) } finally { @@ -267,6 +353,95 @@ class AuthenticationManager @Inject constructor( } } + /** + * Makes [duressPin] the duress PIN of the vault whose DEK is [primaryDek] (the open session's + * own; the caller checked the PIN). [duressPin] stays the caller's to wipe. Refused when it + * is the PIN or the wipe PIN. The decoy vault's DEK is kept in `decoy.key`, sealed under a + * key derived from [primaryDek], so a changed duress PIN opens the same decoy notes; when no + * decoy vault exists yet a new DEK is made and [onNewVault] runs first (it deletes what may + * be left of an earlier decoy database, which nothing can open any more). Counted and refused + * during a lockout like [configureWipePin]. + */ + @Suppress("ReturnCount") // Fail-closed gates before the slot is written. + fun configureDuressPin(duressPin: CharArray, primaryDek: ByteArray, onNewVault: () -> Boolean): DuressPinChange = + synchronized(attemptLock) { + if (!isWellFormed(duressPin) || primaryDek.size != DEK_LENGTH || remainingLockoutMillis() > 0) { + return DuressPinChange.FAILED + } + val slot = store.getBytes(PIN_SLOT_KEY) ?: return DuressPinChange.FAILED + val parsed = try { + parseSlot(slot) + } finally { + SensitiveDataCleaner.clear(slot) + } ?: return DuressPinChange.FAILED + try { + val keys = try { + derivePinKeys(duressPin, parsed.salt, activeKeySlot()) + } catch (_: PinFactorException) { + return DuressPinChange.FAILED + } + try { + val dek = openSlotOrNull(parsed, keys.kek) + if (dek != null) { + SensitiveDataCleaner.clear(dek) + return if (chargeMiss()) DuressPinChange.SAME_AS_PIN else DuressPinChange.FAILED + } + val wipeSlot = store.getBytes(WIPE_SLOT_KEY) + val isWipePin = wipeSlot != null && + try { + opensWipeSlot(wipeSlot, keys.wipeKek, parsed.salt) + } finally { + SensitiveDataCleaner.clear(wipeSlot) + } + if (isWipePin) return if (chargeMiss()) DuressPinChange.IN_USE else DuressPinChange.FAILED + writeDuressSlot(keys.decoyKek, parsed.salt, primaryDek, onNewVault) + } finally { + keys.wipe() + } + } finally { + parsed.wipe() + } + } + + /** + * Turns the duress PIN off and forgets the decoy vault's key: both entries become random + * bytes again, as on a vault that never had one. The caller then deletes the decoy database. + */ + fun removeDuressPin(): Boolean = synchronized(attemptLock) { + val slot = randomSlot() + val key = randomSlot() + try { + store.edit { + putBytes(DECOY_SLOT_KEY, slot) + putBytes(DECOY_KEY_KEY, key) + } + } finally { + SensitiveDataCleaner.clear(slot) + SensitiveDataCleaner.clear(key) + } + } + + /** + * Whether the vault whose DEK is [primaryDek] has a decoy vault. Only that vault's own session + * can tell: `decoy.key` is random bytes otherwise, and a decoy session has no [primaryDek]. + */ + fun hasDecoyVault(primaryDek: ByteArray): Boolean = decoyKeyState(primaryDek) != null + + /** + * True when the vault whose DEK is [primaryDek] has a decoy vault whose duress PIN stopped + * working: a new PIN slot with another key or salt (see [configurePin]) turned it off. + * Setting it again opens the same decoy notes. Kept inside `decoy.key` itself (its AAD), so + * the store shows nothing. + */ + fun duressPinTurnedOff(primaryDek: ByteArray): Boolean = decoyKeyState(primaryDek) == false + + /** Whether `decoy.key` opens under [primaryDek] and the duress slot is live; null: no decoy vault. */ + private fun decoyKeyState(primaryDek: ByteArray): Boolean? { + val key = openDecoyKeyOrNull(primaryDek) ?: return null + SensitiveDataCleaner.clear(key.dek) + return key.active + } + /** * True after the PIN key had to be reset (see [configurePin]): any wipe PIN was turned off * and Settings asks to set it again. Cleared by setting or removing the wipe PIN. @@ -282,15 +457,25 @@ class AuthenticationManager @Inject constructor( val hasPinSlot = hasPinSlot() val needsDecoy = hasPinSlot && !store.contains(WIPE_SLOT_KEY) val needsKeySlot = hasPinSlot && !store.contains(KEY_SLOT_KEY) - if (!needsDecoy && !needsKeySlot) return true + // The duress slot and the decoy key the same way: random bytes until a duress PIN is set. + val needsDuress = hasPinSlot && (!store.contains(DECOY_SLOT_KEY) || !store.contains(DECOY_KEY_KEY)) + if (!needsDecoy && !needsKeySlot && !needsDuress) return true val decoy = decoyWipeSlot() + val duressSlot = randomSlot() + val decoyKey = randomSlot() return try { store.edit { if (needsDecoy) putBytes(WIPE_SLOT_KEY, decoy) if (needsKeySlot) putInt(KEY_SLOT_KEY, PinKeySlot.A.ordinal) + if (needsDuress) { + putBytes(DECOY_SLOT_KEY, duressSlot) + putBytes(DECOY_KEY_KEY, decoyKey) + } } } finally { SensitiveDataCleaner.clear(decoy) + SensitiveDataCleaner.clear(duressSlot) + SensitiveDataCleaner.clear(decoyKey) } } @@ -382,15 +567,20 @@ class AuthenticationManager @Inject constructor( removePrefix(PIN_PREFIX) removePrefix(LOCKOUT_PREFIX) removePrefix(AUTH_PREFIX) + removePrefix(DECOY_PREFIX) remove(WIPE_PENDING_KEY) } PinKeySlot.entries.forEach(factor::delete) } - /** Opens whichever slot [keys] open; see [unlockWithPin]. */ - private fun checkBothSlots(pin: CharArray, parsed: ParsedSlot, keys: PinKeys, allowWipe: Boolean): PinUnlock { + /** + * Opens whichever slot [keys] open; see [unlockWithPin]. Settings never lets one PIN open two + * slots; should it happen anyway, the order is PIN, duress, wipe: never the erase while a + * PIN could also open a vault. + */ + private fun checkSlots(pin: CharArray, parsed: ParsedSlot, keys: PinKeys, check: Check): PinUnlock { val dek = openSlotOrNull(parsed, keys.kek) - // Tried even when the PIN slot already opened: no early exit. + // Both tried even when the PIN slot already opened: no early exit. val wipeSlot = store.getBytes(WIPE_SLOT_KEY) val wipeMatch = wipeSlot != null && try { @@ -398,18 +588,44 @@ class AuthenticationManager @Inject constructor( } finally { SensitiveDataCleaner.clear(wipeSlot) } - return when { - dek != null -> { - store.edit { removePrefix(LOCKOUT_PREFIX) } - PinUnlock.Success(dek) + val decoyDek = openDecoySlotOrNull(keys.decoyKek, parsed.salt) + val matched = match(dek, decoyDek, wipeMatch, check.purpose) + // Whatever was opened and not handed out is wiped here. + if (dek != null && matched !is PinUnlock.Success) SensitiveDataCleaner.clear(dek) + if (decoyDek != null && matched !is PinUnlock.Decoy) SensitiveDataCleaner.clear(decoyDek) + return when (matched) { + null -> eraseVault(pin, parsed.salt, keys) + + is PinUnlock.Success -> { + store.edit { + removePrefix(LOCKOUT_PREFIX) + remove(STRIKES_KEY) + } + matched } - wipeMatch && allowWipe -> eraseVault(pin, parsed.salt, keys) + // The running lockout ends as on the PIN, but the misses since the PIN was last right + // stay: typing the duress PIN between guesses never buys more guesses at the PIN. + is PinUnlock.Decoy -> { + store.edit { + removePrefix(LOCKOUT_PREFIX) + putInt(STRIKES_KEY, check.strikes) + } + matched + } - else -> PinUnlock.WrongPin + else -> matched } } + /** What [checkSlots] hands out for [purpose]; null is the wipe PIN's erase. */ + private fun match(dek: ByteArray?, decoyDek: ByteArray?, wipeMatch: Boolean, purpose: Purpose): PinUnlock? = when { + dek != null && purpose != Purpose.VERIFY_DECOY -> PinUnlock.Success(dek) + decoyDek != null && purpose != Purpose.VERIFY -> PinUnlock.Decoy(decoyDek) + wipeMatch && purpose == Purpose.UNLOCK && dek == null && decoyDek == null -> null + else -> PinUnlock.WrongPin + } + /** * Phases 1 and 2 of the wipe-PIN erase (see [unlockWithPin]). If no key can be made in the * other Keystore slot, the new PIN slot uses the current key: the old slots are still @@ -438,17 +654,24 @@ class AuthenticationManager @Inject constructor( } val dek = ByteArray(DEK_LENGTH).also { SecureRandom().nextBytes(it) } val decoy = decoyWipeSlot() + // The decoy vault goes with the erase: its slot and key become random bytes, and its + // database is deleted with the old one (see SecurityManager.completePendingWipe). + val duressSlot = randomSlot() + val decoyKey = randomSlot() return try { val slot = sealSlot(dek, kek, salt) val erased = try { store.edit { putBytes(PIN_SLOT_KEY, slot) putBytes(WIPE_SLOT_KEY, decoy) + putBytes(DECOY_SLOT_KEY, duressSlot) + putBytes(DECOY_KEY_KEY, decoyKey) putInt(KEY_SLOT_KEY, keySlot.ordinal) putInt(WIPE_PENDING_KEY, WipeStage.DATABASE.ordinal) remove(WIPE_NOTICE_KEY) remove(BIOMETRIC_ENABLED_KEY) removePrefix(LOCKOUT_PREFIX) + remove(STRIKES_KEY) ERASED_PREFIXES.forEach { removePrefix(it) } } } finally { @@ -461,6 +684,8 @@ class AuthenticationManager @Inject constructor( SensitiveDataCleaner.clear(dek) SensitiveDataCleaner.clear(kek) SensitiveDataCleaner.clear(decoy) + SensitiveDataCleaner.clear(duressSlot) + SensitiveDataCleaner.clear(decoyKey) } } @@ -490,22 +715,72 @@ class AuthenticationManager @Inject constructor( kek: ByteArray, salt: ByteArray, keySlot: PinKeySlot, - change: WipeSlotChange, + changes: SlotChanges, ): Boolean { + val (change, decoyChange) = changes val slot = sealSlot(dek, kek, salt) val decoy = if (change == WipeSlotChange.KEEP) null else decoyWipeSlot() + val duressSlot = if (decoyChange == WipeSlotChange.KEEP) null else randomSlot() + // A dead duress slot is recorded in `decoy.key` (sealed again as turned off); without a + // decoy vault it gets fresh random bytes instead, so the entry changes either way. A first + // PIN (no salt yet) starts a vault without any decoy key either. + val decoyKey = if (duressSlot == null && store.contains(DECOY_KEY_KEY)) null else turnedOffDecoyKey(dek) return try { store.edit { putBytes(PIN_SLOT_KEY, slot) decoy?.let { putBytes(WIPE_SLOT_KEY, it) } + duressSlot?.let { putBytes(DECOY_SLOT_KEY, it) } + decoyKey?.let { putBytes(DECOY_KEY_KEY, it) } putInt(KEY_SLOT_KEY, keySlot.ordinal) putInt(AUTH_TYPE_KEY, AuthType.PIN.ordinal) if (change == WipeSlotChange.TURN_OFF_AND_TELL) putBoolean(WIPE_NOTICE_KEY, true) removePrefix(LOCKOUT_PREFIX) + remove(STRIKES_KEY) } } finally { SensitiveDataCleaner.clear(slot) decoy?.let(SensitiveDataCleaner::clear) + duressSlot?.let(SensitiveDataCleaner::clear) + decoyKey?.let(SensitiveDataCleaner::clear) + } + } + + /** + * Seals the duress slot (and the decoy key) for the PIN whose duress KEK is [decoyKek]; see + * [configureDuressPin]. + */ + @Suppress("ReturnCount") + private fun writeDuressSlot( + decoyKek: ByteArray, + salt: ByteArray, + primaryDek: ByteArray, + onNewVault: () -> Boolean, + ): DuressPinChange { + val kept = openDecoyKeyOrNull(primaryDek)?.dek + // No decoy vault yet: whatever an earlier one left can never open again, so it goes first. + if (kept == null && !onNewVault()) return DuressPinChange.FAILED + val decoyDek = kept ?: ByteArray(DEK_LENGTH).also { SecureRandom().nextBytes(it) } + return try { + val duressSlot = sealDecoy(decoyDek, decoyKek, decoySlotAad(salt)) + val decoyKey = sealDecoyKey(decoyDek, primaryDek) + val written = try { + store.edit { + putBytes(DECOY_SLOT_KEY, duressSlot) + putBytes(DECOY_KEY_KEY, decoyKey) + } + } finally { + SensitiveDataCleaner.clear(duressSlot) + SensitiveDataCleaner.clear(decoyKey) + } + when { + !written -> DuressPinChange.FAILED + kept == null -> DuressPinChange.SET_NEW_VAULT + else -> DuressPinChange.SET + } + } catch (_: GeneralSecurityException) { + DuressPinChange.FAILED + } finally { + SensitiveDataCleaner.clear(decoyDek) } } @@ -545,9 +820,10 @@ class AuthenticationManager @Inject constructor( } } - private fun chargeAttempt(failures: Int): Boolean = store.edit { - putInt(FAILURES_KEY, failures) - val penalty = penaltyFor(failures) + private fun chargeAttempt(misses: Misses): Boolean = store.edit { + putInt(FAILURES_KEY, misses.failures) + putInt(STRIKES_KEY, misses.strikes) + val penalty = penaltyFor(misses.failures, misses.strikes) if (penalty > 0L) { putLong(PENALTY_KEY, penalty) putLong(ANCHOR_KEY, clock.elapsedRealtime()) @@ -557,9 +833,18 @@ class AuthenticationManager @Inject constructor( } } - private fun refundAttempt(previousFailures: Int) { + /** + * A refused PIN in Settings because it is another PIN (the PIN, wipe or duress PIN): it + * tells what that PIN is, so it costs a miss like a wrong guess, charged before the answer + * is returned. + */ + private fun chargeMiss(): Boolean = + chargeAttempt(Misses(store.getInt(FAILURES_KEY, 0), store.getInt(STRIKES_KEY, 0)).next()) + + private fun refundAttempt(previous: Misses) { store.edit { - putInt(FAILURES_KEY, previousFailures) + putInt(FAILURES_KEY, previous.failures) + putInt(STRIKES_KEY, previous.strikes) remove(PENALTY_KEY) } } @@ -581,6 +866,7 @@ class AuthenticationManager @Inject constructor( stretched = stretched, kek = Hkdf.sha256(ikm = ikm, salt = salt, info = KEK_INFO, length = DEK_LENGTH), wipeKek = Hkdf.sha256(ikm = ikm, salt = salt, info = WIPE_INFO, length = DEK_LENGTH), + decoyKek = Hkdf.sha256(ikm = ikm, salt = salt, info = DECOY_INFO, length = DEK_LENGTH), ) } finally { SensitiveDataCleaner.clear(hardware) @@ -618,113 +904,110 @@ class AuthenticationManager @Inject constructor( } } - /** `version ‖ salt ‖ iv ‖ AES-GCM(dek)`, with the version and salt authenticated as AAD. */ - private fun sealSlot(dek: ByteArray, kek: ByteArray, salt: ByteArray): ByteArray { - val cipher = Cipher.getInstance(AES_GCM) - cipher.init(Cipher.ENCRYPT_MODE, SecretKeySpec(kek, "AES")) - cipher.updateAAD(aad(salt)) - val encrypted = cipher.doFinal(dek) - val iv = cipher.iv + /** Whether [decoyKek] opens the duress slot; the DEK it holds is wiped at once. */ + private fun opensDecoySlot(decoyKek: ByteArray, salt: ByteArray): Boolean { + val dek = openDecoySlotOrNull(decoyKek, salt) ?: return false + SensitiveDataCleaner.clear(dek) + return true + } + + /** The decoy DEK in the duress slot, or null when [decoyKek] does not open it. */ + private fun openDecoySlotOrNull(decoyKek: ByteArray, salt: ByteArray): ByteArray? { + val slot = store.getBytes(DECOY_SLOT_KEY) ?: return null return try { - byteArrayOf(SLOT_VERSION) + salt + iv + encrypted + openDecoy(slot, decoyKek, decoySlotAad(salt)) } finally { - SensitiveDataCleaner.clear(encrypted) + SensitiveDataCleaner.clear(slot) } } - private fun parseSlot(slot: ByteArray): ParsedSlot? { - if (slot.size != SLOT_LENGTH || slot[0] != SLOT_VERSION) return null - var offset = 1 - val salt = slot.copyOfRange(offset, offset + PIN_SALT_SIZE) - offset += PIN_SALT_SIZE - val iv = slot.copyOfRange(offset, offset + IV_LENGTH) - offset += IV_LENGTH - return ParsedSlot(salt, iv, slot.copyOfRange(offset, slot.size)) - } - - /** The DEK in [slot], or null when [kek] does not open it. The caller wipes the result. */ - private fun openSlotOrNull(slot: ParsedSlot, kek: ByteArray): ByteArray? = try { - val cipher = Cipher.getInstance(AES_GCM) - cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(kek, "AES"), GCMParameterSpec(GCM_TAG_LENGTH, slot.iv)) - cipher.updateAAD(aad(slot.salt)) - cipher.doFinal(slot.ciphertext) - } catch (_: GeneralSecurityException) { - // AEADBadTagException included: a wrong PIN. - null - } - - private fun aad(salt: ByteArray): ByteArray = PIN_AAD + byteArrayOf(SLOT_VERSION) + salt - - /** `version ‖ iv ‖ AES-GCM(random marker)`, bound to the PIN slot's [salt] as AAD. */ - private fun sealWipeSlot(wipeKek: ByteArray, salt: ByteArray): ByteArray { - val marker = ByteArray(WIPE_MARKER_LENGTH).also { SecureRandom().nextBytes(it) } - val cipher = Cipher.getInstance(AES_GCM) - cipher.init(Cipher.ENCRYPT_MODE, SecretKeySpec(wipeKek, "AES")) - cipher.updateAAD(wipeAad(salt)) - val encrypted = try { - cipher.doFinal(marker) - } finally { - SensitiveDataCleaner.clear(marker) - } + /** The decoy DEK sealed under [primaryDek] in `decoy.key`, or null when there is none. */ + private fun openDecoyKeyOrNull(primaryDek: ByteArray): DecoyKey? { + val sealed = store.getBytes(DECOY_KEY_KEY) ?: return null + val key = decoyKeyKek(primaryDek) return try { - byteArrayOf(SLOT_VERSION) + cipher.iv + encrypted + // Both tried, as the attempt tries all slots: one AES-GCM open more is no tell here. + val active = openDecoy(sealed, key, DECOY_KEY_AAD) + val off = openDecoy(sealed, key, DECOY_KEY_OFF_AAD) + when { + active != null -> DecoyKey(active, active = true) + off != null -> DecoyKey(off, active = false) + else -> null + } } finally { - SensitiveDataCleaner.clear(encrypted) + SensitiveDataCleaner.clear(sealed) + SensitiveDataCleaner.clear(key) } } - /** Whether [wipeKek] authenticates [wipeSlot]; a decoy never does. */ - private fun opensWipeSlot(wipeSlot: ByteArray, wipeKek: ByteArray, salt: ByteArray): Boolean { - if (wipeSlot.size != WIPE_SLOT_LENGTH || wipeSlot[0] != SLOT_VERSION) return false - val iv = wipeSlot.copyOfRange(1, 1 + IV_LENGTH) - val ciphertext = wipeSlot.copyOfRange(1 + IV_LENGTH, wipeSlot.size) + private fun sealDecoyKey(decoyDek: ByteArray, primaryDek: ByteArray, active: Boolean = true): ByteArray { + val key = decoyKeyKek(primaryDek) return try { - val cipher = Cipher.getInstance(AES_GCM) - cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(wipeKek, "AES"), GCMParameterSpec(GCM_TAG_LENGTH, iv)) - cipher.updateAAD(wipeAad(salt)) - SensitiveDataCleaner.clear(cipher.doFinal(ciphertext)) - true - } catch (_: GeneralSecurityException) { - false + sealDecoy(decoyDek, key, if (active) DECOY_KEY_AAD else DECOY_KEY_OFF_AAD) } finally { - SensitiveDataCleaner.clear(iv) - SensitiveDataCleaner.clear(ciphertext) + SensitiveDataCleaner.clear(key) } } - private fun wipeAad(salt: ByteArray): ByteArray = WIPE_AAD + byteArrayOf(SLOT_VERSION) + salt - - /** Random bytes shaped like a wipe slot (the same version byte and length): no PIN opens it. */ - private fun decoyWipeSlot(): ByteArray = ByteArray(WIPE_SLOT_LENGTH).also { - SecureRandom().nextBytes(it) - it[0] = SLOT_VERSION - } - - private class ParsedSlot(val salt: ByteArray, val iv: ByteArray, val ciphertext: ByteArray) { - fun wipe() { - SensitiveDataCleaner.clear(salt) - SensitiveDataCleaner.clear(iv) - SensitiveDataCleaner.clear(ciphertext) + /** `decoy.key` for a dead duress slot: the decoy DEK sealed as turned off, or random bytes. */ + private fun turnedOffDecoyKey(primaryDek: ByteArray): ByteArray { + val decoyDek = openDecoyKeyOrNull(primaryDek)?.dek ?: return randomSlot() + return try { + sealDecoyKey(decoyDek, primaryDek, active = false) + } finally { + SensitiveDataCleaner.clear(decoyDek) } } + /** The decoy DEK from `decoy.key`, the caller's to wipe; [active]: the duress slot opens it. */ + private class DecoyKey(val dek: ByteArray, val active: Boolean) + /** Both KEKs of one KDF run, and its PBKDF2 half (reused if the vault is erased). */ private class PinKeys( val keySlot: PinKeySlot, val stretched: ByteArray, val kek: ByteArray, val wipeKek: ByteArray, + val decoyKek: ByteArray, ) { fun wipe() { SensitiveDataCleaner.clear(stretched) SensitiveDataCleaner.clear(kek) SensitiveDataCleaner.clear(wipeKek) + SensitiveDataCleaner.clear(decoyKek) } } + /** What a counted PIN attempt is for: which matches it hands out. */ + private enum class Purpose { + /** The lock screen: the PIN, the duress PIN, or the wipe PIN's erase. */ + UNLOCK, + + /** A re-check inside the real vault: the PIN only. */ + VERIFY, + + /** A re-check inside the decoy vault: the duress PIN only. */ + VERIFY_DECOY, + } + + /** What one counted attempt checks for, and the strikes before it was charged. */ + private class Check(val purpose: Purpose, val strikes: Int) + + /** + * The miss counters: [failures] since any vault last opened (the lockout as the user sees + * it), [strikes] since the PIN itself was last right. Only the PIN, a new PIN slot and the + * erase clear [strikes]; see [penaltyFor]. + */ + private data class Misses(val failures: Int, val strikes: Int) { + fun next() = Misses(failures + 1, strikes + 1) + } + /** What a new PIN slot does to the wipe slot (see [configurePin]). */ private enum class WipeSlotChange { KEEP, TURN_OFF, TURN_OFF_AND_TELL } + /** What a new PIN slot does to the wipe slot and to the duress slot. */ + private data class SlotChanges(val wipe: WipeSlotChange, val decoy: WipeSlotChange) + companion object { private const val PIN_PREFIX = "pin." private const val PIN_SLOT_KEY = "pin.slot" @@ -732,6 +1015,9 @@ class AuthenticationManager @Inject constructor( private const val WIPE_NOTICE_KEY = "pin.wipe.notice" private const val KEY_SLOT_KEY = "pin.key_slot" private const val WIPE_PENDING_KEY = "wipe.pending" + private const val DECOY_SLOT_KEY = "pin.decoy.slot" + private const val DECOY_PREFIX = "decoy." + private const val DECOY_KEY_KEY = "decoy.key" private const val AUTH_PREFIX = "auth." private const val AUTH_TYPE_KEY = "auth.type" private const val BIOMETRIC_ENABLED_KEY = "auth.biometric_enabled" @@ -741,27 +1027,26 @@ class AuthenticationManager @Inject constructor( private const val ANCHOR_KEY = "lockout.anchor_elapsed" private const val BOOT_KEY = "lockout.boot" + /** Outside [LOCKOUT_PREFIX]: the duress PIN clears that prefix, never this; see [Misses]. */ + private const val STRIKES_KEY = "pin.strikes" + const val MAX_ATTEMPTS = 5 const val FIRST_LOCKOUT_MS = 30_000L const val MAX_LOCKOUT_MS = 24 * 60 * 60_000L + + /** + * Misses since the PIN was last right that are free of the lockout on top of + * [MAX_ATTEMPTS], for typos around the duress PIN: past them each miss is locked out + * even right after the duress PIN opened the decoy vault. + */ + const val STRIKE_GRACE = 5 private const val MAX_DOUBLINGS = 12 - private const val PIN_SALT_SIZE = 16 private const val PBKDF2_ITERATIONS = 600_000 - private const val GCM_TAG_LENGTH = 128 - private const val GCM_TAG_BYTES = GCM_TAG_LENGTH / 8 - private const val IV_LENGTH = 12 - private const val DEK_LENGTH = 32 - private const val SLOT_VERSION: Byte = 3 - private const val SLOT_LENGTH = 1 + PIN_SALT_SIZE + IV_LENGTH + DEK_LENGTH + GCM_TAG_BYTES - private const val WIPE_MARKER_LENGTH = 32 - private const val WIPE_SLOT_LENGTH = 1 + IV_LENGTH + WIPE_MARKER_LENGTH + GCM_TAG_BYTES - private const val AES_GCM = "AES/GCM/NoPadding" - - private val PIN_AAD = "encly/pin/slot/v3".toByteArray(Charsets.UTF_8) + private val KEK_INFO = "encly/pin/kek/v3".toByteArray(Charsets.UTF_8) - private val WIPE_AAD = "encly/pin/wipe-slot/v3".toByteArray(Charsets.UTF_8) private val WIPE_INFO = "encly/pin/wipe/v3".toByteArray(Charsets.UTF_8) + private val DECOY_INFO = "encly/pin/decoy/v3".toByteArray(Charsets.UTF_8) /** Every other way back to an erased vault's DEK: recovery, backup-key and biometric slots. */ private val ERASED_PREFIXES = listOf( @@ -773,10 +1058,12 @@ class AuthenticationManager @Inject constructor( /** * The lockout after [failures] consecutive misses: none before [MAX_ATTEMPTS], then * 30 s, doubling each miss (1 min, 2, 4, 8, 16, 32 min, ~1 h, ~2 h ...) up to 24 h. + * [strikes] (misses since the PIN was last right) count too, less [STRIKE_GRACE]. */ - fun penaltyFor(failures: Int): Long { - if (failures < MAX_ATTEMPTS) return 0L - val doublings = (failures - MAX_ATTEMPTS).coerceAtMost(MAX_DOUBLINGS) + fun penaltyFor(failures: Int, strikes: Int = failures): Long { + val misses = maxOf(failures, strikes - STRIKE_GRACE) + if (misses < MAX_ATTEMPTS) return 0L + val doublings = (misses - MAX_ATTEMPTS).coerceAtMost(MAX_DOUBLINGS) return (FIRST_LOCKOUT_MS shl doublings).coerceAtMost(MAX_LOCKOUT_MS) } } diff --git a/app/src/main/java/com/pasich/encly/core/security/AutoLock.kt b/app/src/main/java/com/pasich/encly/core/security/AutoLock.kt index a1ccf61f..f27f51b8 100644 --- a/app/src/main/java/com/pasich/encly/core/security/AutoLock.kt +++ b/app/src/main/java/com/pasich/encly/core/security/AutoLock.kt @@ -15,6 +15,8 @@ enum class AutoLockDelay(val millis: Long) { SECONDS_30(30_000L), MINUTE_1(60_000L), MINUTES_2(120_000L), + MINUTES_15(900_000L), + MINUTES_30(1_800_000L), ; companion object { diff --git a/app/src/main/java/com/pasich/encly/core/security/DuressSlots.kt b/app/src/main/java/com/pasich/encly/core/security/DuressSlots.kt new file mode 100644 index 00000000..c394f28b --- /dev/null +++ b/app/src/main/java/com/pasich/encly/core/security/DuressSlots.kt @@ -0,0 +1,71 @@ +package com.pasich.encly.core.security + +import com.pasich.encly.core.security.PinSlots.AES_GCM +import com.pasich.encly.core.security.PinSlots.DEK_LENGTH +import com.pasich.encly.core.security.PinSlots.GCM_TAG_BYTES +import com.pasich.encly.core.security.PinSlots.GCM_TAG_LENGTH +import com.pasich.encly.core.security.PinSlots.IV_LENGTH +import com.pasich.encly.core.security.PinSlots.SLOT_VERSION +import java.security.GeneralSecurityException +import java.security.SecureRandom +import javax.crypto.Cipher +import javax.crypto.spec.GCMParameterSpec +import javax.crypto.spec.SecretKeySpec + +/** + * The duress slot (`version ‖ iv ‖ AES-GCM_dkek(decoy DEK)`, bound to the PIN slot's salt) and + * the decoy key (the same DEK sealed under a sub-key of the real vault's DEK): both 61 bytes, + * the same as random filler, so the store has the same shape with or without a duress PIN. + */ +internal object DuressSlots { + private const val DECOY_SLOT_LENGTH = 1 + IV_LENGTH + DEK_LENGTH + GCM_TAG_BYTES + private val DECOY_AAD = "encly/pin/decoy-slot/v3".toByteArray(Charsets.UTF_8) + val DECOY_KEY_AAD = "encly/decoy/key/v1".toByteArray(Charsets.UTF_8) + byteArrayOf(SLOT_VERSION) + + /** The decoy key while its duress PIN is turned off (a new PIN slot killed the duress slot). */ + val DECOY_KEY_OFF_AAD = "encly/decoy/key/off/v1".toByteArray(Charsets.UTF_8) + byteArrayOf(SLOT_VERSION) + private val DECOY_KEY_SALT = "encly/decoy/key/salt/v1".toByteArray(Charsets.UTF_8) + private val DECOY_KEY_INFO = "encly/decoy/key/kek/v1".toByteArray(Charsets.UTF_8) + + /** A sub-key of the vault's DEK, so the SQLCipher key itself is never an AES-GCM key. */ + fun decoyKeyKek(primaryDek: ByteArray): ByteArray = + Hkdf.sha256(ikm = primaryDek, salt = DECOY_KEY_SALT, info = DECOY_KEY_INFO, length = DEK_LENGTH) + + /** `version ‖ iv ‖ AES-GCM(dek)`: the shape of the duress slot and the decoy key, 61 bytes. */ + fun sealDecoy(dek: ByteArray, kek: ByteArray, aad: ByteArray): ByteArray { + val cipher = Cipher.getInstance(AES_GCM) + cipher.init(Cipher.ENCRYPT_MODE, SecretKeySpec(kek, "AES")) + cipher.updateAAD(aad) + val encrypted = cipher.doFinal(dek) + return try { + byteArrayOf(SLOT_VERSION) + cipher.iv + encrypted + } finally { + SensitiveDataCleaner.clear(encrypted) + } + } + + fun openDecoy(sealed: ByteArray, kek: ByteArray, aad: ByteArray): ByteArray? { + if (sealed.size != DECOY_SLOT_LENGTH || sealed[0] != SLOT_VERSION) return null + val iv = sealed.copyOfRange(1, 1 + IV_LENGTH) + val ciphertext = sealed.copyOfRange(1 + IV_LENGTH, sealed.size) + return try { + val cipher = Cipher.getInstance(AES_GCM) + cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(kek, "AES"), GCMParameterSpec(GCM_TAG_LENGTH, iv)) + cipher.updateAAD(aad) + cipher.doFinal(ciphertext) + } catch (_: GeneralSecurityException) { + null + } finally { + SensitiveDataCleaner.clear(iv) + SensitiveDataCleaner.clear(ciphertext) + } + } + + fun decoySlotAad(salt: ByteArray): ByteArray = DECOY_AAD + byteArrayOf(SLOT_VERSION) + salt + + /** Random bytes shaped like a duress slot or decoy key: nothing opens them. */ + fun randomSlot(): ByteArray = ByteArray(DECOY_SLOT_LENGTH).also { + SecureRandom().nextBytes(it) + it[0] = SLOT_VERSION + } +} diff --git a/app/src/main/java/com/pasich/encly/core/security/PinSlots.kt b/app/src/main/java/com/pasich/encly/core/security/PinSlots.kt new file mode 100644 index 00000000..3561791c --- /dev/null +++ b/app/src/main/java/com/pasich/encly/core/security/PinSlots.kt @@ -0,0 +1,120 @@ +package com.pasich.encly.core.security + +import java.security.GeneralSecurityException +import java.security.SecureRandom +import javax.crypto.Cipher +import javax.crypto.spec.GCMParameterSpec +import javax.crypto.spec.SecretKeySpec + +/** + * The byte formats of the PIN-derived slots in the vault store, and their AES-256-GCM sealing: + * the PIN slot (`version ‖ salt ‖ iv ‖ AES-GCM(DEK)`), the wipe slot and the duress slot (both + * `version ‖ iv ‖ AES-GCM(…)`, bound to the PIN slot's salt; see [DuressSlots] for the duress + * slot and the decoy key). Pure functions: + * which slot is read or written, and when, is [AuthenticationManager]'s business. + */ +internal object PinSlots { + const val PIN_SALT_SIZE = 16 + const val DEK_LENGTH = 32 + const val SLOT_VERSION: Byte = 3 + const val GCM_TAG_LENGTH = 128 + const val GCM_TAG_BYTES = GCM_TAG_LENGTH / 8 + const val IV_LENGTH = 12 + private const val SLOT_LENGTH = 1 + PIN_SALT_SIZE + IV_LENGTH + DEK_LENGTH + GCM_TAG_BYTES + private const val WIPE_MARKER_LENGTH = 32 + private const val WIPE_SLOT_LENGTH = 1 + IV_LENGTH + WIPE_MARKER_LENGTH + GCM_TAG_BYTES + const val AES_GCM = "AES/GCM/NoPadding" + + private val PIN_AAD = "encly/pin/slot/v3".toByteArray(Charsets.UTF_8) + private val WIPE_AAD = "encly/pin/wipe-slot/v3".toByteArray(Charsets.UTF_8) + + /** `version ‖ salt ‖ iv ‖ AES-GCM(dek)`, with the version and salt authenticated as AAD. */ + fun sealSlot(dek: ByteArray, kek: ByteArray, salt: ByteArray): ByteArray { + val cipher = Cipher.getInstance(AES_GCM) + cipher.init(Cipher.ENCRYPT_MODE, SecretKeySpec(kek, "AES")) + cipher.updateAAD(aad(salt)) + val encrypted = cipher.doFinal(dek) + val iv = cipher.iv + return try { + byteArrayOf(SLOT_VERSION) + salt + iv + encrypted + } finally { + SensitiveDataCleaner.clear(encrypted) + } + } + + fun parseSlot(slot: ByteArray): ParsedSlot? { + if (slot.size != SLOT_LENGTH || slot[0] != SLOT_VERSION) return null + var offset = 1 + val salt = slot.copyOfRange(offset, offset + PIN_SALT_SIZE) + offset += PIN_SALT_SIZE + val iv = slot.copyOfRange(offset, offset + IV_LENGTH) + offset += IV_LENGTH + return ParsedSlot(salt, iv, slot.copyOfRange(offset, slot.size)) + } + + /** The DEK in [slot], or null when [kek] does not open it. The caller wipes the result. */ + fun openSlotOrNull(slot: ParsedSlot, kek: ByteArray): ByteArray? = try { + val cipher = Cipher.getInstance(AES_GCM) + cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(kek, "AES"), GCMParameterSpec(GCM_TAG_LENGTH, slot.iv)) + cipher.updateAAD(aad(slot.salt)) + cipher.doFinal(slot.ciphertext) + } catch (_: GeneralSecurityException) { + // AEADBadTagException included: a wrong PIN. + null + } + + fun aad(salt: ByteArray): ByteArray = PIN_AAD + byteArrayOf(SLOT_VERSION) + salt + + /** `version ‖ iv ‖ AES-GCM(random marker)`, bound to the PIN slot's [salt] as AAD. */ + fun sealWipeSlot(wipeKek: ByteArray, salt: ByteArray): ByteArray { + val marker = ByteArray(WIPE_MARKER_LENGTH).also { SecureRandom().nextBytes(it) } + val cipher = Cipher.getInstance(AES_GCM) + cipher.init(Cipher.ENCRYPT_MODE, SecretKeySpec(wipeKek, "AES")) + cipher.updateAAD(wipeAad(salt)) + val encrypted = try { + cipher.doFinal(marker) + } finally { + SensitiveDataCleaner.clear(marker) + } + return try { + byteArrayOf(SLOT_VERSION) + cipher.iv + encrypted + } finally { + SensitiveDataCleaner.clear(encrypted) + } + } + + /** Whether [wipeKek] authenticates [wipeSlot]; a decoy never does. */ + fun opensWipeSlot(wipeSlot: ByteArray, wipeKek: ByteArray, salt: ByteArray): Boolean { + if (wipeSlot.size != WIPE_SLOT_LENGTH || wipeSlot[0] != SLOT_VERSION) return false + val iv = wipeSlot.copyOfRange(1, 1 + IV_LENGTH) + val ciphertext = wipeSlot.copyOfRange(1 + IV_LENGTH, wipeSlot.size) + return try { + val cipher = Cipher.getInstance(AES_GCM) + cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(wipeKek, "AES"), GCMParameterSpec(GCM_TAG_LENGTH, iv)) + cipher.updateAAD(wipeAad(salt)) + SensitiveDataCleaner.clear(cipher.doFinal(ciphertext)) + true + } catch (_: GeneralSecurityException) { + false + } finally { + SensitiveDataCleaner.clear(iv) + SensitiveDataCleaner.clear(ciphertext) + } + } + + fun wipeAad(salt: ByteArray): ByteArray = WIPE_AAD + byteArrayOf(SLOT_VERSION) + salt + + /** Random bytes shaped like a wipe slot (the same version byte and length): no PIN opens it. */ + fun decoyWipeSlot(): ByteArray = ByteArray(WIPE_SLOT_LENGTH).also { + SecureRandom().nextBytes(it) + it[0] = SLOT_VERSION + } + + class ParsedSlot(val salt: ByteArray, val iv: ByteArray, val ciphertext: ByteArray) { + fun wipe() { + SensitiveDataCleaner.clear(salt) + SensitiveDataCleaner.clear(iv) + SensitiveDataCleaner.clear(ciphertext) + } + } +} diff --git a/app/src/main/java/com/pasich/encly/core/security/SecurityManager.kt b/app/src/main/java/com/pasich/encly/core/security/SecurityManager.kt index cca8e2af..5793afd0 100644 --- a/app/src/main/java/com/pasich/encly/core/security/SecurityManager.kt +++ b/app/src/main/java/com/pasich/encly/core/security/SecurityManager.kt @@ -5,6 +5,7 @@ import androidx.fragment.app.FragmentActivity import com.pasich.encly.core.AppLogger import com.pasich.encly.data.backup.BackupManager import com.pasich.encly.data.database.SecureDatabaseManager +import com.pasich.encly.data.database.VaultFile import com.pasich.encly.data.handoff.HandoffStagingSweeper import java.security.SecureRandom import java.util.concurrent.atomic.AtomicBoolean @@ -69,6 +70,14 @@ class SecurityManager @Inject constructor( @Volatile private var sessionDek: ByteArray? = null + // Which vault [sessionDek] opens: the real one, or the decoy vault of the duress PIN. Every + // operation that touches the real vault's key slots is refused in a decoy session. + @Volatile + private var sessionVault: VaultFile? = null + + // The vault the last session of this process opened, so a switch moves [eraseEpoch]. + private var lastVault: VaultFile? = null + // True while the open session was unlocked with the recovery phrase and no new PIN has // been set since. The user proved the vault's strongest secret, so they may replace a // forgotten PIN without typing it. @@ -84,9 +93,10 @@ class SecurityManager @Inject constructor( // My Notes hand-off) find the vault already in use. private val startupFinished = AtomicBoolean(false) - // Changes whenever a wipe-PIN erase replaces the database, and never on lock or unlock, so a - // screen remembered before an erase (the note open when the app re-locked) is not shown in - // the empty vault. Random per process: one saved by an earlier process never matches. + // Changes whenever a wipe-PIN erase replaces the database or an unlock opens the other vault + // (real or decoy), and never on a lock and unlock of the same vault, so a screen remembered + // before (the note open when the app re-locked) is not shown in another vault. Random per + // process: one saved by an earlier process never matches. private val eraseEpoch = AtomicLong(SecureRandom().nextLong()) var securityStatus = InitialStatus.NO @@ -164,24 +174,40 @@ class SecurityManager @Inject constructor( fun hasRecoverySeed(): Boolean = seedPhraseManager.hasRecoverySeed() + /** + * True while the open session is the decoy vault the duress PIN opened. Nothing on screen + * may say so: Settings looks the same, and this layer refuses what belongs to the real vault. + */ + fun isDecoySession(): Boolean = sessionDek != null && sessionVault == VaultFile.DECOY + /** Sets a new PIN slot around the live DEK. [pin] is wiped. */ - fun configurePin(pin: CharArray): Boolean { + fun configurePin(pin: CharArray): Boolean = changePin(pin) == PinChange.SET + + /** + * [configurePin], telling why it was refused ([PinChange.IN_USE]: the duress PIN). Never in a + * decoy session: the PIN slot holds the real vault's DEK. + */ + fun changePin(pin: CharArray): PinChange { + if (isDecoySession()) { + SensitiveDataCleaner.clear(pin) + return PinChange.FAILED + } val dek = currentKeyCopy() - val configured = try { - dek != null && authenticationManager.configurePin(pin, dek) + val change = try { + if (dek == null) PinChange.FAILED else authenticationManager.changePin(pin, dek) } finally { dek?.let(SensitiveDataCleaner::clear) SensitiveDataCleaner.clear(pin) } - if (configured) sessionUnlockedWithRecovery = false - return configured + if (change == PinChange.SET) sessionUnlockedWithRecovery = false + return change } /** * Whether a new PIN may be set without the current one: only in a session the recovery * phrase unlocked (the PIN was forgotten), until a new PIN is set or the vault locks. */ - fun canResetPinWithoutCurrent(): Boolean = sessionUnlockedWithRecovery && sessionDek != null + fun canResetPinWithoutCurrent(): Boolean = sessionUnlockedWithRecovery && sessionDek != null && !isDecoySession() /** * One PIN attempt from the lock screen; [pin] is wiped. The wipe PIN reports @@ -210,6 +236,13 @@ class SecurityManager @Inject constructor( SensitiveDataCleaner.clear(attempt.dek) } + // The same result as the PIN: nothing tells the two apart on screen. + is PinUnlock.Decoy -> try { + if (unlockDecoy(attempt.dek)) VaultUnlockResult.SUCCESS else VaultUnlockResult.DB_ERROR + } finally { + SensitiveDataCleaner.clear(attempt.dek) + } + PinUnlock.WrongPin -> VaultUnlockResult.INVALID_CREDENTIAL PinUnlock.LockedOut -> VaultUnlockResult.LOCKED_OUT @@ -237,7 +270,8 @@ class SecurityManager @Inject constructor( } fun enrollBiometric(activity: FragmentActivity, onResult: (Boolean) -> Unit) { - val dek = currentKeyCopy() + // The biometric slot opens the real vault only. + val dek = if (isDecoySession()) null else currentKeyCopy() if (dek == null) { onResult(false) return @@ -251,11 +285,17 @@ class SecurityManager @Inject constructor( } fun disableBiometric() { + if (isDecoySession()) return biometricManager.disable() authenticationManager.markBiometricEnabled(false) } fun confirmBiometric(activity: FragmentActivity, onResult: (Boolean) -> Unit) { + // A fingerprint belongs to the real vault; it never confirms anything in the decoy one. + if (isDecoySession()) { + onResult(false) + return + } biometricManager.authenticate( activity, BiometricManager.BiometricType.SETTINGS_TOGGLE, @@ -280,7 +320,7 @@ class SecurityManager @Inject constructor( * or the vault has no backup-key slot. The caller must wipe the returned key. */ fun copyBackupRootKey(): ByteArray? { - val dek = sessionDek?.copyOf() ?: return null + val dek = primarySessionKeyCopy() ?: return null return try { seedPhraseManager.unwrapBackupRoot(dek) } finally { @@ -293,7 +333,7 @@ class SecurityManager @Inject constructor( * created in user-managed mode. See [SeedPhraseManager.addRecoverySeed]. */ fun addRecoverySeed(phrase: CharArray): Boolean { - val dek = sessionDek?.copyOf() ?: return false + val dek = primarySessionKeyCopy() ?: return false return try { seedPhraseManager.addRecoverySeed(phrase, dek) } finally { @@ -307,7 +347,7 @@ class SecurityManager @Inject constructor( * sealed to them still need them. The caller wipes [phrase]. */ fun replaceRecoverySeed(phrase: CharArray): Boolean { - val dek = sessionDek?.copyOf() ?: return false + val dek = primarySessionKeyCopy() ?: return false return try { seedPhraseManager.replaceRecoverySeed(phrase, dek) } finally { @@ -317,7 +357,7 @@ class SecurityManager @Inject constructor( /** Adds the backup-key slot to an unlocked recovery-seed vault; [phrase] must be its seed. */ fun createBackupKey(phrase: CharArray): Boolean { - val dek = sessionDek?.copyOf() ?: return false + val dek = primarySessionKeyCopy() ?: return false return try { seedPhraseManager.createBackupKey(phrase, dek) } finally { @@ -327,9 +367,12 @@ class SecurityManager @Inject constructor( fun pinLockoutRemainingMillis(): Long = authenticationManager.remainingLockoutMillis() - /** Re-authentication with the PIN; counts like an unlock attempt. [pin] is wiped. */ + /** + * Re-authentication with the PIN of the open vault (the duress PIN in a decoy session); + * counts like an unlock attempt. [pin] is wiped. + */ fun verifyPin(pin: CharArray): Boolean = try { - authenticationManager.verifyPinAuth(pin) + authenticationManager.verifyPinAuth(pin, decoy = isDecoySession()) } finally { SensitiveDataCleaner.clear(pin) } @@ -354,7 +397,27 @@ class SecurityManager @Inject constructor( val opened = secureDatabaseManager.unlockDatabase(dek, allowCreate = allowCreate || replaceDatabase) val ok = opened && (!replaceDatabase || recordErasedDatabase()) if (ok) { - setSessionKey(dek) + setSessionKey(dek, VaultFile.PRIMARY) + securityStatus = InitialStatus.MAIN + } + ok + } + } + + /** + * Opens the decoy vault with its DEK (from the duress slot); the caller keeps and wipes + * [dek]. Its database is created on the first opening. A decoy database that does not open + * fails the unlock: it is never deleted and never falls back to the real vault. + */ + private fun unlockDecoy(dek: ByteArray): Boolean { + if (dek.size != DEK_LENGTH) return false + return synchronized(databaseLock) { + // An erase still in progress replaced the duress slot; nothing may open meanwhile. + if (authenticationManager.pendingWipe() != null) return@synchronized false + val create = !secureDatabaseManager.hasEncryptedDatabase(VaultFile.DECOY) + val ok = secureDatabaseManager.unlockDatabase(dek, create, VaultFile.DECOY) + if (ok) { + setSessionKey(dek, VaultFile.DECOY) securityStatus = InitialStatus.MAIN } ok @@ -399,8 +462,9 @@ class SecurityManager @Inject constructor( } /** - * Changes whenever a wipe-PIN erase replaces the database, never on lock or unlock: what was - * remembered under another value belongs to a vault that is gone. + * Changes whenever a wipe-PIN erase replaces the database or the other vault (real or decoy) + * is opened, never on a lock and unlock of the same vault: what was remembered under another + * value belongs to a vault that is gone or not open. */ fun eraseEpoch(): Long = eraseEpoch.get() @@ -411,13 +475,64 @@ class SecurityManager @Inject constructor( * PIN. [pin] is wiped. */ fun configureWipePin(pin: CharArray): WipePinChange = try { - if (sessionDek == null) WipePinChange.FAILED else authenticationManager.configureWipePin(pin) + if (!primarySessionOpen()) WipePinChange.FAILED else authenticationManager.configureWipePin(pin) } finally { SensitiveDataCleaner.clear(pin) } /** Turns the wipe PIN off; only in an unlocked session, after the caller checked the PIN. */ - fun removeWipePin(): Boolean = sessionDek != null && authenticationManager.removeWipePin() + fun removeWipePin(): Boolean = primarySessionOpen() && authenticationManager.removeWipePin() + + // --- duress PIN (decoy vault) --------------------------------------------------------- + + /** + * Sets (or replaces) the duress PIN; only in a session of the real vault, after the caller + * checked the PIN. A replaced duress PIN opens the same decoy notes. [pin] is wiped. + */ + fun configureDuressPin(pin: CharArray): DuressPinChange { + val dek = primarySessionKeyCopy() + return try { + if (dek == null) { + DuressPinChange.FAILED + } else { + authenticationManager.configureDuressPin(pin, dek) { + secureDatabaseManager.wipeIfClosed(VaultFile.DECOY) + } + } + } finally { + dek?.let(SensitiveDataCleaner::clear) + SensitiveDataCleaner.clear(pin) + } + } + + /** + * Turns the duress PIN off and deletes the decoy vault with its notes; only in a session of + * the real vault, after the caller checked the PIN and the user confirmed. The real vault is + * not touched. The key goes first: a kill before the files are deleted leaves a database + * nothing can open, which the next duress PIN set replaces. + */ + fun removeDuressPin(): Boolean { + if (!primarySessionOpen()) return false + return authenticationManager.removeDuressPin() && secureDatabaseManager.wipeIfClosed(VaultFile.DECOY) + } + + /** + * Whether the real vault has a decoy vault (its duress PIN may have been turned off, see + * [duressPinTurnedOff]); false outside a session of the real vault. + */ + fun hasDuressPin(): Boolean = withPrimaryKey(authenticationManager::hasDecoyVault) + + /** Whether the real vault's duress PIN stopped working; false outside a session of the real vault. */ + fun duressPinTurnedOff(): Boolean = withPrimaryKey(authenticationManager::duressPinTurnedOff) + + private fun withPrimaryKey(check: (ByteArray) -> Boolean): Boolean { + val dek = primarySessionKeyCopy() ?: return false + return try { + check(dek) + } finally { + SensitiveDataCleaner.clear(dek) + } + } /** * Completes first-run setup only after a PIN slot exists and the bootstrap DEK opens SQLCipher. @@ -508,18 +623,39 @@ class SecurityManager @Inject constructor( private fun currentKeyCopy(): ByteArray? = sessionDek?.copyOf() ?: seedPhraseManager.copyBootstrapKey() - private fun setSessionKey(dek: ByteArray) { + private fun primarySessionOpen(): Boolean = sessionDek != null && sessionVault == VaultFile.PRIMARY + + /** A copy of the real vault's DEK, or null when no session of the real vault is open. */ + private fun primarySessionKeyCopy(): ByteArray? = if (sessionVault == + VaultFile.PRIMARY + ) { + sessionDek?.copyOf() + } else { + null + } + + private fun setSessionKey(dek: ByteArray, vault: VaultFile) { clearSessionKey() + // What a screen remembered in the other vault must not come back in this one. + if (lastVault != null && lastVault != vault) eraseEpoch.incrementAndGet() + lastVault = vault sessionDek = dek.copyOf() + sessionVault = vault } private fun clearSessionKey() { sessionDek?.let(SensitiveDataCleaner::clear) sessionDek = null + sessionVault = null sessionUnlockedWithRecovery = false } - fun wipeAndReset() { + /** + * Erases everything: both vaults, every key slot. Never from a decoy session, where it + * changes nothing and returns false. + */ + fun wipeAndReset(): Boolean { + if (isDecoySession()) return false secureDatabaseManager.wipe() clearSessionKey() biometricManager.disable() @@ -529,6 +665,7 @@ class SecurityManager @Inject constructor( vaultStore.clear() appFlags.edit().clear().commit() securityStatus = InitialStatus.ONBOARDING + return true } fun getSettingsAuth(): AuthSettings = AuthSettings( @@ -537,6 +674,8 @@ class SecurityManager @Inject constructor( isUserCreatedSeedKey = seedPhraseManager.hasRecoverySeed(), hasPinSlot = authenticationManager.hasPinSlot(), wipePinTurnedOff = authenticationManager.wipePinTurnedOff(), + hasDuressPin = hasDuressPin(), + duressPinTurnedOff = duressPinTurnedOff(), ) } @@ -550,4 +689,8 @@ data class AuthSettings( val isUserCreatedSeedKey: Boolean, val hasPinSlot: Boolean = authType == AuthType.PIN, val wipePinTurnedOff: Boolean = false, + /** The real vault has a duress PIN (known only in its own session). */ + val hasDuressPin: Boolean = false, + /** A new PIN slot turned the duress PIN off; setting it again keeps the notes. */ + val duressPinTurnedOff: Boolean = false, ) diff --git a/app/src/main/java/com/pasich/encly/core/security/SessionLockManager.kt b/app/src/main/java/com/pasich/encly/core/security/SessionLockManager.kt index 715e6feb..4828c702 100644 --- a/app/src/main/java/com/pasich/encly/core/security/SessionLockManager.kt +++ b/app/src/main/java/com/pasich/encly/core/security/SessionLockManager.kt @@ -172,7 +172,7 @@ class SessionLockManager @Inject constructor( /** The grace no longer holds: too long away, the screen went off, or the keyguard is up. */ private fun graceBroken(stoppedAt: Long, lockedAway: Boolean): Boolean = - clock() - stoppedAt > awayLimitMs || lockedAway || deviceLock.isDeviceLocked() + clock() - stoppedAt >= awayLimitMs || lockedAway || deviceLock.isDeviceLocked() private fun onAwayTimeout(screenOff: Boolean) { if (foreground || suspendedAt == NONE) return diff --git a/app/src/main/java/com/pasich/encly/core/security/cipher/SQLCipherUtils.kt b/app/src/main/java/com/pasich/encly/core/security/cipher/SQLCipherUtils.kt index 8d86b912..af4b7a39 100644 Binary files a/app/src/main/java/com/pasich/encly/core/security/cipher/SQLCipherUtils.kt and b/app/src/main/java/com/pasich/encly/core/security/cipher/SQLCipherUtils.kt differ diff --git a/app/src/main/java/com/pasich/encly/data/backup/BackupManager.kt b/app/src/main/java/com/pasich/encly/data/backup/BackupManager.kt index 184754a0..69fdc484 100644 --- a/app/src/main/java/com/pasich/encly/data/backup/BackupManager.kt +++ b/app/src/main/java/com/pasich/encly/data/backup/BackupManager.kt @@ -40,17 +40,23 @@ class BackupManager @Inject constructor( * would look like a good backup but could never be restored. */ suspend fun createBackup(): ByteArray { - val payload = guarded { BackupMapper.toPayload(store.snapshot(), clock()) } + realVaultOnly() + val boundStore = guarded { store.bindToCurrentSession() } + val payload = guarded { BackupMapper.toPayload(boundStore.snapshot(), clock()) } val root = securityManager.copyBackupRootKey() ?: throw BackupException(BackupError.VAULT_UNAVAILABLE) - val plaintext = BackupPayloadCodec.encode(payload) return try { - if (BackupFormat.fileSizeFor(plaintext.size) > maxFileBytes) { - throw BackupException(BackupError.TOO_LARGE) + guarded { boundStore.assertCurrentSession() } + val plaintext = BackupPayloadCodec.encode(payload) + try { + if (BackupFormat.fileSizeFor(plaintext.size) > maxFileBytes) { + throw BackupException(BackupError.TOO_LARGE) + } + BackupCipher.seal(plaintext, BackupSecret.RecoveryRoot(root)) + } finally { + SensitiveDataCleaner.clear(plaintext) } - BackupCipher.seal(plaintext, BackupSecret.RecoveryRoot(root)) } finally { - SensitiveDataCleaner.clear(plaintext) SensitiveDataCleaner.clear(root) } } @@ -61,8 +67,10 @@ class BackupManager @Inject constructor( /** * Authenticates, decrypts and validates [file] without touching the database. * [phrase] must already be normalized ([normalizeRecoveryPhrase]); the caller wipes it. + * Refused in the decoy vault, like export. */ fun decrypt(file: ByteArray, phrase: CharArray): BackupPayload { + realVaultOnly() if (!securityManager.isValidRecoveryPhrase(phrase)) { throw BackupException(BackupError.INVALID_PHRASE) } @@ -77,9 +85,22 @@ class BackupManager @Inject constructor( /** Lower-case words separated by single spaces, as stored for the vault's recovery slot. */ fun normalizeRecoveryPhrase(input: CharArray): CharArray = BackupKeys.normalizeMnemonic(input) - /** Imports into the unlocked vault in one transaction; nothing is written on failure. */ - suspend fun import(payload: BackupPayload, mode: ImportMode, tagMatch: TagMatch = TagMatch.UID): ImportSummary = - guarded { BackupImporter.import(payload, mode, store, tagMatch) } + /** + * Imports into the unlocked vault in one transaction; nothing is written on failure. Refused + * in the decoy vault. + */ + suspend fun import(payload: BackupPayload, mode: ImportMode, tagMatch: TagMatch = TagMatch.UID): ImportSummary { + realVaultOnly() + return guarded { BackupImporter.import(payload, mode, store.bindToCurrentSession(), tagMatch) } + } + + /** + * Backups belong to the real vault. In the decoy vault every step fails like a storage error + * would, so the Backup screen looks like it does anywhere else and says nothing more. + */ + private fun realVaultOnly() { + if (securityManager.isDecoySession()) throw BackupException(BackupError.IO) + } fun lastExportAt(): Long? = secureStoragePrefs.getLong(LAST_EXPORT_KEY, 0L).takeIf { it > 0L } diff --git a/app/src/main/java/com/pasich/encly/data/backup/RoomVaultDataStore.kt b/app/src/main/java/com/pasich/encly/data/backup/RoomVaultDataStore.kt index b1270445..442c43f1 100644 --- a/app/src/main/java/com/pasich/encly/data/backup/RoomVaultDataStore.kt +++ b/app/src/main/java/com/pasich/encly/data/backup/RoomVaultDataStore.kt @@ -1,6 +1,7 @@ package com.pasich.encly.data.backup import androidx.room.withTransaction +import com.pasich.encly.data.database.DatabaseLease import com.pasich.encly.data.database.DatabaseProvider import com.pasich.encly.data.model.Note import com.pasich.encly.data.model.Subtask @@ -8,35 +9,49 @@ import com.pasich.encly.data.model.Tag import com.pasich.encly.data.model.Task import javax.inject.Inject -/** [VaultDataStore] over the currently unlocked SQLCipher/Room vault. */ -class RoomVaultDataStore @Inject constructor(private val databaseProvider: DatabaseProvider) : VaultDataStore { - private fun database() = databaseProvider.getDatabase() - private fun dao() = database().backupDao() - - override suspend fun snapshot(): VaultSnapshot = database().withTransaction { - VaultSnapshot( - notes = dao().allNotes(), - tags = dao().allTags(), - tasks = dao().allTasks(), - subtasks = dao().allSubtasks(), - ) +/** [VaultDataStore] over a single SQLCipher/Room opening. */ +class RoomVaultDataStore private constructor( + private val databaseProvider: DatabaseProvider, + private val lease: DatabaseLease?, +) : VaultDataStore { + @Inject constructor(databaseProvider: DatabaseProvider) : this(databaseProvider, null) + + override fun bindToCurrentSession(): VaultDataStore = + RoomVaultDataStore(databaseProvider, databaseProvider.captureLease() ?: error("Vault locked")) + + override fun assertCurrentSession() { + database() + } + + private fun database() = databaseProvider.getDatabase(lease ?: error("Vault data store is not bound")) + + override suspend fun snapshot(): VaultSnapshot = database().let { db -> + db.withTransaction { + VaultSnapshot( + notes = db.backupDao().allNotes(), + tags = db.backupDao().allTags(), + tasks = db.backupDao().allTasks(), + subtasks = db.backupDao().allSubtasks(), + ) + } } override suspend fun inTransaction(block: suspend () -> R): R = database().withTransaction { block() } override suspend fun deleteAll() { - dao().deleteAllNotes() + val dao = database().backupDao() + dao.deleteAllNotes() // The foreign key would cascade them with their tasks; deleted explicitly all the same. - dao().deleteAllSubtasks() - dao().deleteAllTasks() - dao().deleteAllTags() + dao.deleteAllSubtasks() + dao.deleteAllTasks() + dao.deleteAllTags() } - override suspend fun insertTag(tag: Tag): Long = dao().insertTag(tag) + override suspend fun insertTag(tag: Tag): Long = database().backupDao().insertTag(tag) - override suspend fun insertNote(note: Note): Long = dao().insertNote(note) + override suspend fun insertNote(note: Note): Long = database().backupDao().insertNote(note) - override suspend fun insertTask(task: Task): Long = dao().insertTask(task) + override suspend fun insertTask(task: Task): Long = database().backupDao().insertTask(task) - override suspend fun insertSubtask(subtask: Subtask): Long = dao().insertSubtask(subtask) + override suspend fun insertSubtask(subtask: Subtask): Long = database().backupDao().insertSubtask(subtask) } diff --git a/app/src/main/java/com/pasich/encly/data/backup/VaultDataStore.kt b/app/src/main/java/com/pasich/encly/data/backup/VaultDataStore.kt index ced64432..1d668a8a 100644 --- a/app/src/main/java/com/pasich/encly/data/backup/VaultDataStore.kt +++ b/app/src/main/java/com/pasich/encly/data/backup/VaultDataStore.kt @@ -18,6 +18,12 @@ data class VaultSnapshot( * [RoomVaultDataStore]; tests use an in-memory one. */ interface VaultDataStore { + /** Capture one vault opening for a multi-step export or import. */ + fun bindToCurrentSession(): VaultDataStore = this + + /** Refuse a result if its vault closed while the operation was suspended. */ + fun assertCurrentSession() = Unit + suspend fun snapshot(): VaultSnapshot /** Runs [block] atomically: if it throws, none of its writes are kept. */ diff --git a/app/src/main/java/com/pasich/encly/data/database/DatabaseProvider.kt b/app/src/main/java/com/pasich/encly/data/database/DatabaseProvider.kt index 01f193ab..5a0c57e5 100644 --- a/app/src/main/java/com/pasich/encly/data/database/DatabaseProvider.kt +++ b/app/src/main/java/com/pasich/encly/data/database/DatabaseProvider.kt @@ -1,11 +1,16 @@ package com.pasich.encly.data.database -/** - * Supplies the Room instance currently backed by the unlocked encrypted vault. - * - * Keeping this contract separate from the SQLCipher lifecycle manager lets repository tests - * exercise DAO error handling without loading the native SQLCipher library on the JVM. - */ +/** One opening of the encrypted database. Identity, not its contents, grants DAO access. */ +class DatabaseLease internal constructor() + +/** Supplies Room only to callers still bound to the opening in which they were created. */ interface DatabaseProvider { - fun getDatabase(): AppDatabase + /** Null while locked. The returned lease is revoked before Room closes. */ + fun captureLease(): DatabaseLease? + + /** + * Refuses a lease from an earlier opening, even when another database (the other vault's, + * real or decoy) is now open. + */ + fun getDatabase(lease: DatabaseLease): AppDatabase } diff --git a/app/src/main/java/com/pasich/encly/data/database/SecureDatabaseManager.kt b/app/src/main/java/com/pasich/encly/data/database/SecureDatabaseManager.kt index cc2c4fc2..fb735f05 100644 --- a/app/src/main/java/com/pasich/encly/data/database/SecureDatabaseManager.kt +++ b/app/src/main/java/com/pasich/encly/data/database/SecureDatabaseManager.kt @@ -15,17 +15,28 @@ import java.security.MessageDigest import javax.inject.Inject import javax.inject.Singleton +/** + * The two database files a vault can have: the real one, and the decoy vault the duress PIN + * opens (its own file, its own random DEK). At most one of them is open at a time. + */ +enum class VaultFile(internal val fileName: String) { + PRIMARY("database.db"), + DECOY("vault_b.db"), +} + /** * Owns the SQLCipher-encrypted Room database and its unlocked lifecycle. * * A committed vault must already have an encrypted database file. Database creation is allowed - * only during the atomic first-run setup path. + * only during the atomic first-run setup path (and the first opening of a decoy vault). */ @Singleton class SecureDatabaseManager @Inject constructor(@param:ApplicationContext private val context: Context) : DatabaseProvider { private var database: AppDatabase? = null private var isUnlocked = false + private var lease: DatabaseLease? = null + private var openFile: VaultFile? = null /** * The passphrase buffer handed to Room's [SupportOpenHelperFactory]. SQLCipher keeps this @@ -37,11 +48,16 @@ class SecureDatabaseManager @Inject constructor(@param:ApplicationContext privat fun isDatabaseUnlocked(): Boolean = isUnlocked - fun hasEncryptedDatabase(): Boolean = - SQLCipherUtils.getDatabaseState(context, DB_NAME) == SQLCipherUtils.State.ENCRYPTED + fun hasEncryptedDatabase(file: VaultFile = VaultFile.PRIMARY): Boolean = + SQLCipherUtils.getDatabaseState(context, file.fileName) == SQLCipherUtils.State.ENCRYPTED - override fun getDatabase(): AppDatabase = database?.takeIf { isUnlocked } - ?: error("Database accessed before unlock — call unlockDatabase() first") + @Synchronized + override fun captureLease(): DatabaseLease? = lease + + @Synchronized + override fun getDatabase(lease: DatabaseLease): AppDatabase = database?.takeIf { + isUnlocked && this.lease === lease + } ?: error("Database session expired") /** * Opens the vault with the raw 32-byte [dek]; the caller keeps (and wipes) [dek]. @@ -50,17 +66,22 @@ class SecureDatabaseManager @Inject constructor(@param:ApplicationContext privat * the page key directly instead of running its own PBKDF2 over it on every connection: the * DEK is already a uniformly random 256-bit key. * - * While open, a second call succeeds only with the same key (compared in constant time); - * any other key is refused, never silently accepted. + * While open, a second call succeeds only with the same key (compared in constant time) and + * the same [file]; any other key or file is refused, never silently accepted. */ + @Synchronized + fun unlockDatabase(dek: ByteArray, allowCreate: Boolean = false): Boolean = + unlockDatabase(dek, allowCreate, VaultFile.PRIMARY) + + /** [unlockDatabase] for [file]: the real vault's database or the decoy vault's. */ @Suppress("ReturnCount") // Fail-closed vault-state/key gates are clearer as early exits. @Synchronized - fun unlockDatabase(dek: ByteArray, allowCreate: Boolean = false): Boolean { + fun unlockDatabase(dek: ByteArray, allowCreate: Boolean, file: VaultFile): Boolean { if (dek.size != DEK_LENGTH) return false val passphrase = rawKeyPassphrase(dek) if (isUnlocked) { return try { - roomPassphrase?.let { MessageDigest.isEqual(it, passphrase) } == true + openFile == file && roomPassphrase?.let { MessageDigest.isEqual(it, passphrase) } == true } finally { SensitiveDataCleaner.clear(passphrase) } @@ -69,7 +90,7 @@ class SecureDatabaseManager @Inject constructor(@param:ApplicationContext privat var openedDatabase: AppDatabase? = null return try { - when (SQLCipherUtils.getDatabaseState(context, DB_NAME)) { + when (SQLCipherUtils.getDatabaseState(context, file.fileName)) { SQLCipherUtils.State.DOES_NOT_EXIST -> if (!allowCreate) return false // Never silently adopt/migrate a plaintext database into a committed vault. @@ -80,7 +101,7 @@ class SecureDatabaseManager @Inject constructor(@param:ApplicationContext privat // No destructive fallback: a schema change without a migration must fail to open // (and be caught in tests), never drop the user's notes. - val opened = VaultSchema.databaseBuilder(context.applicationContext, DB_NAME) + val opened = VaultSchema.databaseBuilder(context.applicationContext, file.fileName) .openHelperFactory(SupportOpenHelperFactory(passphrase, VaultConnectionHook, false)) .build() openedDatabase = opened @@ -92,11 +113,15 @@ class SecureDatabaseManager @Inject constructor(@param:ApplicationContext privat database = opened roomPassphrase = passphrase isUnlocked = true + openFile = file + lease = DatabaseLease() true } catch (e: Exception) { openedDatabase?.close() database = null isUnlocked = false + openFile = null + lease = null AppLogger.e(TAG, "Failed to unlock the database", e) false } finally { @@ -106,12 +131,13 @@ class SecureDatabaseManager @Inject constructor(@param:ApplicationContext privat } } - private fun deleteDatabaseFiles() { + private fun deleteDatabaseFiles(file: VaultFile) { try { - val dbFile = context.getDatabasePath(DB_NAME) + val dbFile = context.getDatabasePath(file.fileName) File(dbFile.absolutePath).delete() File(dbFile.absolutePath + "-wal").delete() File(dbFile.absolutePath + "-shm").delete() + File(dbFile.absolutePath + "-journal").delete() } catch (e: Exception) { AppLogger.e(TAG, "Failed to delete database files", e) } @@ -119,33 +145,39 @@ class SecureDatabaseManager @Inject constructor(@param:ApplicationContext privat @Synchronized fun reset() { + lease = null database?.close() database = null isUnlocked = false + openFile = null roomPassphrase?.let(SensitiveDataCleaner::clear) roomPassphrase = null } + /** Closes and deletes both databases: the real vault's and the decoy vault's. */ + @Synchronized fun wipe() { reset() - deleteDatabaseFiles() + VaultFile.entries.forEach(::deleteDatabaseFiles) } /** * Deletes the database files unless the database is open, checked and done under the same * lock [unlockDatabase] holds, so a database being opened (or just created) meanwhile is * never deleted. True when they were deleted. + * + * With [only], just that file goes, refused only while that one is open: the decoy vault's + * database is deleted from a session of the real vault, whose file is never touched. */ @Synchronized - fun wipeIfClosed(): Boolean { - if (isUnlocked) return false - deleteDatabaseFiles() + fun wipeIfClosed(only: VaultFile? = null): Boolean { + if (isUnlocked && (only == null || openFile == only)) return false + (only?.let(::listOf) ?: VaultFile.entries).forEach(::deleteDatabaseFiles) return true } companion object { private const val TAG = "SecureDatabaseManager" - private const val DB_NAME = "database.db" private const val DEK_LENGTH = 32 private val HEX = "0123456789abcdef".toCharArray() diff --git a/app/src/main/java/com/pasich/encly/data/datasource/local/SettingsLocalDataSource.kt b/app/src/main/java/com/pasich/encly/data/datasource/local/SettingsLocalDataSource.kt index 9ff41bbb..5f873e6e 100644 --- a/app/src/main/java/com/pasich/encly/data/datasource/local/SettingsLocalDataSource.kt +++ b/app/src/main/java/com/pasich/encly/data/datasource/local/SettingsLocalDataSource.kt @@ -7,6 +7,7 @@ import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.intPreferencesKey import com.pasich.encly.domain.enums.NoteSortOption import com.pasich.encly.domain.model.FontStyleType +import com.pasich.encly.domain.model.NoteLineSpacing import com.pasich.encly.domain.model.ThemePalette import com.pasich.encly.domain.model.ThemeSettings import com.pasich.encly.domain.model.ThemeType @@ -27,9 +28,11 @@ const val SHOW_TASKS = "show_tasks" const val SIMPLE_EDIT = "simple_edit" const val FONT_SIZE = "font_size" const val FONT_STYLE = "font_style" +const val NOTE_LINE_SPACING = "note_line_spacing" const val APPEARANCE_MIGRATED = "appearance_migrated_v1" @Singleton +@Suppress("TooManyFunctions") // One small getter/setter pair per persisted editor or display setting. class SettingsLocalDataSource @Inject constructor(private val dataStore: DataStore) { private companion object { @@ -42,6 +45,7 @@ class SettingsLocalDataSource @Inject constructor(private val dataStore: DataSto val simpleEdit = booleanPreferencesKey(SIMPLE_EDIT) val fontSize = intPreferencesKey(FONT_SIZE) val fontStyle = intPreferencesKey(FONT_STYLE) + val noteLineSpacing = intPreferencesKey(NOTE_LINE_SPACING) val appearanceMigrated = booleanPreferencesKey(APPEARANCE_MIGRATED) } @@ -102,6 +106,10 @@ class SettingsLocalDataSource @Inject constructor(private val dataStore: DataSto enumAtOrDefault(preferences[fontStyle], FontStyleType.DEFAULT) }.distinctUntilChanged() + val noteLineSpacingFlow: Flow = dataStore.data.map { preferences -> + enumAtOrDefault(preferences[noteLineSpacing], NoteLineSpacing.DEFAULT) + }.distinctUntilChanged() + suspend fun setSortNotes(option: NoteSortOption) { dataStore.edit { preferences -> preferences[sortNotes] = option.index @@ -155,6 +163,12 @@ class SettingsLocalDataSource @Inject constructor(private val dataStore: DataSto preferences[fontStyle] = value.ordinal } } + + suspend fun setNoteLineSpacing(value: NoteLineSpacing) { + dataStore.edit { preferences -> + preferences[noteLineSpacing] = value.ordinal + } + } } /** diff --git a/app/src/main/java/com/pasich/encly/data/handoff/InlineHtml.kt b/app/src/main/java/com/pasich/encly/data/handoff/InlineHtml.kt index 8c45b376..2b8275fb 100644 --- a/app/src/main/java/com/pasich/encly/data/handoff/InlineHtml.kt +++ b/app/src/main/java/com/pasich/encly/data/handoff/InlineHtml.kt @@ -8,6 +8,16 @@ package com.pasich.encly.data.handoff object InlineHtml { private val LINE_BREAK = Regex("""""", RegexOption.IGNORE_CASE) private val TAG = Regex("""]*>""") + + /** + * Only real inline HTML tags (with well-formed attributes, opening, closing or self-closing), + * so typed text such as `Map`, `` or `ad` is left alone. + */ + private val KNOWN_TAG = Regex( + """]*"|'[^'<>]*'|[^\s"'<>=`]+))?)*\s*/?>""", + RegexOption.IGNORE_CASE, + ) private val ENTITY = Regex("""&(#[0-9]{1,7}|#[xX][0-9A-Fa-f]{1,6}|[A-Za-z]{2,8});""") private const val HEX = 16 @@ -31,10 +41,12 @@ object InlineHtml { /** * [html] as plain text: `
` becomes a line break, every other tag is removed, entities * are decoded once (so a typed "<b>" stays the text ""), and the invisible - * characters the editor leaves behind are dropped. Not trimmed. + * characters the editor leaves behind are dropped. Not trimmed. With [knownTagsOnly] only the + * tags of [KNOWN_TAG] are removed and any other angle-bracket text stays, for Markdown that + * is typed, not generated by an editor. */ - fun toPlainText(html: String): String { - val withoutTags = TAG.replace(LINE_BREAK.replace(html, "\n"), "") + fun toPlainText(html: String, knownTagsOnly: Boolean = false): String { + val withoutTags = (if (knownTagsOnly) KNOWN_TAG else TAG).replace(LINE_BREAK.replace(html, "\n"), "") return ENTITY.replace(withoutTags) { decode(it.groupValues[1]) ?: it.value } .replace(NO_BREAK_SPACE, ' ') .replace(ZERO_WIDTH_SPACE.toString(), "") diff --git a/app/src/main/java/com/pasich/encly/data/importer/DocumentTree.kt b/app/src/main/java/com/pasich/encly/data/importer/DocumentTree.kt new file mode 100644 index 00000000..0c1e84dd --- /dev/null +++ b/app/src/main/java/com/pasich/encly/data/importer/DocumentTree.kt @@ -0,0 +1,46 @@ +package com.pasich.encly.data.importer + +import java.io.InputStream + +/** One entry of a [DocumentTree]; [id] is what the tree needs to list or open it. */ +class TreeNode(val id: String, val name: String, val isDirectory: Boolean) + +/** + * A folder the user picked, as the Storage Access Framework shows it ([SafImportFiles]) or, in + * tests, as a plain directory. Listing and opening are all the import needs. + */ +interface DocumentTree { + val rootId: String + + fun children(parentId: String): List + + fun open(id: String): InputStream? +} + +/** + * Walks a [DocumentTree] lazily into [SourceFile]s with paths relative to its root and never + * listing more than [ImportLimits.maxEntries] entries in all, so a cyclic or enormous tree ends + * instead of looping. Deeper than [ImportLimits.maxPathDepth] nothing is opened or listed: the entries + * there are handed on as unsafe, so the reader counts them instead of silently losing them. + * Media is never opened either; the reader recognises it by name. + */ +class TreeWalker(private val tree: DocumentTree, private val limits: ImportLimits = ImportLimits()) { + fun source(): ImportFileSource = ImportFileSource { + entriesLeft = limits.maxEntries + files(tree.rootId, prefix = "", depth = 1) + } + + private var entriesLeft = 0 + + private fun files(parentId: String, prefix: String, depth: Int): Sequence = sequence { + for (node in tree.children(parentId).take(limits.maxEntries)) { + if (entriesLeft-- <= 0) break + val path = if (prefix.isEmpty()) node.name else "$prefix/${node.name}" + when { + depth > limits.maxPathDepth -> yield(SourceFile(path, unsafe = true) { null }) + node.isDirectory -> yieldAll(files(node.id, path, depth + 1)) + else -> yield(SourceFile(path) { tree.open(node.id) }) + } + } + } +} diff --git a/app/src/main/java/com/pasich/encly/data/importer/FormatDetector.kt b/app/src/main/java/com/pasich/encly/data/importer/FormatDetector.kt new file mode 100644 index 00000000..bd8578fb --- /dev/null +++ b/app/src/main/java/com/pasich/encly/data/importer/FormatDetector.kt @@ -0,0 +1,179 @@ +package com.pasich.encly.data.importer + +/** What one selected file turned out to be, judged by its content and never by its extension alone. */ +internal enum class EntryKind { + KEEP, + STANDARD_NOTES, + STANDARD_NOTES_ENCRYPTED, + JOPLIN, + NOTESNOOK, + NOTESNOOK_BACKUP, + + /** Markdown or plain text with no other app's marks. */ + TEXT, + + /** Not a supported export: other JSON, unknown files. Never guessed at. */ + UNSUPPORTED, + + /** A text file that is not valid UTF-8. */ + UNREADABLE, + + /** Media, HTML duplicates, hidden folders, the plain files beside a Standard Notes backup. */ + IGNORED, +} + +/** The verdict on a selection. */ +sealed interface Detection { + data class Found(val source: ImportSource) : Detection + + /** A refused, encrypted export; [source] says whose, for the guidance shown. */ + data class Encrypted(val source: ImportSource) : Detection + + /** Files of more than one app: the user picks the source. */ + data object Ambiguous : Detection + + data object Unsupported : Detection + + data object Empty : Detection +} + +/** + * Recognises exports by what is inside them: + * - Keep: a JSON object with numeric `createdTimestampUsec` and `userEditedTimestampUsec`. + * - Standard Notes: JSON `{version, items}` of typed items; `keyParams`/`auth_params` or a string + * `content` marks an encrypted backup, which is refused. + * - Joplin: Markdown whose last paragraph is `key: value` lines with a 32-hex `id` and `type_`. + * - Notesnook: Markdown front matter with `title`, `created_at` and `updated_at`; a `.nnbackup` + * or backup chunk (`hash_type`, `compressed`/`encrypted`) is an encrypted backup, refused. + * - Anything else that is Markdown or text is generic; other JSON or files are unsupported. + */ +object FormatDetector { + private val FOLDER_SOURCES = mapOf( + EntryKind.KEEP to ImportSource.GOOGLE_KEEP, + EntryKind.STANDARD_NOTES to ImportSource.STANDARD_NOTES, + EntryKind.JOPLIN to ImportSource.JOPLIN, + EntryKind.NOTESNOOK to ImportSource.NOTESNOOK, + ) + + internal fun classify(entries: List, limits: ImportLimits): List { + val itemFolders = entries.filter { it.name.startsWith(StandardNotesParser.BACKUP_FILE_PREFIX) } + .map { it.path.removeSuffix(it.name) + "Items/" } + return entries.map { entry -> + when { + ImportKinds.isNotesnookBackup(entry) -> EntryKind.NOTESNOOK_BACKUP + + ImportKinds.isHidden(entry) || itemFolders.any { entry.path.startsWith(it) } -> EntryKind.IGNORED + + entry.extension == "json" || entry.name.startsWith(StandardNotesParser.BACKUP_FILE_PREFIX) || + isExtensionlessJson(entry) -> json(entry, limits) + + MarkdownNotesParser.isTextFile(entry) -> text(entry) + + ImportKinds.isIgnorable(entry) -> EntryKind.IGNORED + + else -> EntryKind.UNSUPPORTED + } + } + } + + /** Notesnook's backup chunks (`0-plain-`) are JSON without an extension. */ + private fun isExtensionlessJson(entry: ImportEntry): Boolean = + '.' !in entry.name && entry.bytes.firstOrNull { !it.toInt().toChar().isWhitespace() } == '{'.code.toByte() + + private fun json(entry: ImportEntry, limits: ImportLimits): EntryKind { + val text = ImportText.utf8(entry.bytes) + val root = text?.let { ImportText.obj(ImportText.json(it, limits.maxJsonDepth)) } + val shape = StandardNotesParser.shape(root) + return when { + text == null -> EntryKind.UNREADABLE + root == null -> EntryKind.UNSUPPORTED + KeepParser.isNote(root) != null -> EntryKind.KEEP + shape == StandardNotesParser.Shape.DECRYPTED -> EntryKind.STANDARD_NOTES + shape == StandardNotesParser.Shape.ENCRYPTED -> EntryKind.STANDARD_NOTES_ENCRYPTED + "hash_type" in root || ("compressed" in root && "encrypted" in root) -> EntryKind.NOTESNOOK_BACKUP + else -> EntryKind.UNSUPPORTED + } + } + + private fun text(entry: ImportEntry): EntryKind { + val text = ImportText.utf8(entry.bytes) + val doc = text?.let(Frontmatter::split) + return when { + text == null -> EntryKind.UNREADABLE + entry.extension == "md" && JoplinParser.item(text) != null -> EntryKind.JOPLIN + doc != null && doc.has("title") && doc.has("created_at") && doc.has("updated_at") -> EntryKind.NOTESNOOK + else -> EntryKind.TEXT + } + } + + /** Which source the [kinds] of a whole selection point to. */ + internal fun detect(kinds: List): Detection { + val present = kinds.toSet() + val specific = FOLDER_SOURCES.filterKeys { it in present }.values.toSet() + return when { + specific.size > 1 -> Detection.Ambiguous + specific.size == 1 -> Detection.Found(specific.first()) + EntryKind.STANDARD_NOTES_ENCRYPTED in present -> Detection.Encrypted(ImportSource.STANDARD_NOTES) + EntryKind.NOTESNOOK_BACKUP in present -> Detection.Encrypted(ImportSource.NOTESNOOK) + EntryKind.TEXT in present -> Detection.Found(ImportSource.MARKDOWN) + kinds.isEmpty() -> Detection.Empty + else -> Detection.Unsupported + } + } + + /** A manual choice of [source] still has to find something it can read in the selection. */ + internal fun validate(source: ImportSource, kinds: List): Detection { + val present = kinds.toSet() + val accepted = when (source) { + ImportSource.GOOGLE_KEEP -> setOf(EntryKind.KEEP) + ImportSource.STANDARD_NOTES -> setOf(EntryKind.STANDARD_NOTES) + ImportSource.JOPLIN -> setOf(EntryKind.JOPLIN) + ImportSource.NOTESNOOK -> setOf(EntryKind.NOTESNOOK, EntryKind.TEXT) + ImportSource.MARKDOWN -> setOf(EntryKind.TEXT, EntryKind.NOTESNOOK, EntryKind.JOPLIN) + } + return when { + present.any { it in accepted } -> Detection.Found(source) + + source == ImportSource.STANDARD_NOTES && EntryKind.STANDARD_NOTES_ENCRYPTED in present -> + Detection.Encrypted(source) + + source == ImportSource.NOTESNOOK && EntryKind.NOTESNOOK_BACKUP in present -> Detection.Encrypted(source) + + kinds.isEmpty() -> Detection.Empty + + else -> Detection.Unsupported + } + } + + /** Public entry point: the [Detection] for [entries]. */ + fun detect(entries: List, limits: ImportLimits = ImportLimits()): Detection = + detect(classify(entries, limits)) +} + +internal object ImportKinds { + private val IGNORABLE = setOf( + "html", "htm", "css", "js", "png", "jpg", "jpeg", "gif", "webp", "bmp", "svg", "heic", "heif", "tif", + "tiff", "mp3", "m4a", "wav", "ogg", "aac", "amr", "3gp", "mp4", "mov", "webm", "pdf", "ds_store", + ) + + /** A hidden file or folder (`.obsidian/`, `.DS_Store`) or macOS metadata (`__MACOSX/`). */ + fun isHidden(entry: ImportEntry): Boolean = isHidden(entry.path) + + fun isHidden(path: String): Boolean = path.split('/').any { it.startsWith(".") || it == "__MACOSX" } + + fun isIgnorable(entry: ImportEntry): Boolean = entry.extension in IGNORABLE + + /** Images, audio, video and PDFs: attachments, which are not imported. */ + fun isMedia(entry: ImportEntry): Boolean = isMedia(entry.path) + + /** [path] (either separator) names media, judged by its extension alone, so it need not be read. */ + fun isMedia(path: String): Boolean { + val name = path.substringAfterLast('/').substringAfterLast('\\') + val extension = name.substringAfterLast('.', "").lowercase() + return extension in IGNORABLE && extension !in MARKUP + } + + private val MARKUP = setOf("html", "htm", "css", "js", "ds_store") + + fun isNotesnookBackup(entry: ImportEntry): Boolean = entry.extension == "nnbackup" || entry.name == ".nnbackup" +} diff --git a/app/src/main/java/com/pasich/encly/data/importer/Frontmatter.kt b/app/src/main/java/com/pasich/encly/data/importer/Frontmatter.kt new file mode 100644 index 00000000..04a7eee9 --- /dev/null +++ b/app/src/main/java/com/pasich/encly/data/importer/Frontmatter.kt @@ -0,0 +1,101 @@ +package com.pasich.encly.data.importer + +import kotlinx.serialization.SerializationException +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonPrimitive + +/** A Markdown file split into its front matter (a safe subset of YAML) and its body. */ +class MarkdownDocument(private val meta: Map>, val body: String) { + val hasFrontmatter: Boolean get() = meta.isNotEmpty() + + fun has(key: String): Boolean = key in meta + + /** The first value of [key], or null. */ + fun value(key: String): String? = meta[key]?.firstOrNull()?.takeIf { it.isNotBlank() } + + /** The first of [keys] that has a value. */ + fun firstValue(vararg keys: String): String? = keys.firstNotNullOfOrNull(::value) + + /** All values of [key]; a comma separated scalar counts as a list. */ + fun list(key: String): List = meta[key].orEmpty().flatMap { it.split(',') } + + fun flag(key: String): Boolean = value(key)?.lowercase() in TRUE_VALUES + + private companion object { + val TRUE_VALUES = setOf("true", "yes", "1") + } +} + +/** + * Front matter read by hand, with no YAML library and nothing executed: `key: value` lines, a + * `[a, b]` inline list or `- item` lines under a key, quoted values unquoted. Anything else + * (nested maps, block scalars, anchors, tags) is ignored. Without a closing `---` within + * [MAX_LINES] lines the file has no front matter and is all body; nor has a block with no + * `key:` line in it (a horizontal rule, some text, another rule). + */ +object Frontmatter { + private const val MAX_LINES = 200 + private val KEY_VALUE = Regex("""^([A-Za-z0-9_][A-Za-z0-9_ .-]*):(?:[ \t]+(.*))?$""") + private val LIST_ITEM = Regex("""^[ \t]*-[ \t]+(.*)$""") + private val json = Json { isLenient = false } + + fun split(text: String): MarkdownDocument { + val lines = text.lines() + val end = if (lines.firstOrNull()?.trimEnd() == "---") { + (1 until minOf(lines.size, MAX_LINES)).firstOrNull { + lines[it].trimEnd() == "---" || lines[it].trimEnd() == "..." + } + } else { + null + } + return if (end == null || !isFrontmatter(lines.subList(1, end))) { + MarkdownDocument(emptyMap(), text) + } else { + MarkdownDocument(parse(lines.subList(1, end)), lines.drop(end + 1).joinToString("\n")) + } + } + + /** A block is front matter if it is empty or has a `key:` line (list lines only mean something under one). */ + private fun isFrontmatter(block: List): Boolean = + block.all { it.isBlank() } || block.any { KEY_VALUE.matches(it) && !it.first().isWhitespace() } + + private fun parse(lines: List): Map> { + val meta = LinkedHashMap>() + var current: String? = null + for (line in lines) { + val pair = KEY_VALUE.matchEntire(line)?.takeUnless { line.first().isWhitespace() } + if (pair != null) { + current = pair.groupValues[1].trim().lowercase() + meta.getOrPut(current) { ArrayList() } += scalars(pair.groupValues[2].trim()) + } else { + val item = LIST_ITEM.matchEntire(line) + if (item != null && current != null) meta.getValue(current) += unquote(item.groupValues[1].trim()) + } + } + return meta.filterValues { values -> values.any { it.isNotBlank() } } + } + + /** The values on the `key:` line itself: nothing for a block scalar, several for `[a, b]`. */ + private fun scalars(raw: String): List = when { + raw.startsWith("[") && raw.endsWith("]") -> raw.removeSurrounding("[", "]").split(',').map { + unquote(it.trim()) + } + + raw.isNotEmpty() && raw != "|" && raw != ">" -> listOf(unquote(raw)) + + else -> emptyList() + } + + /** A JSON-quoted string is decoded (Notesnook writes titles that way); a single-quoted one is unwrapped. */ + private fun unquote(raw: String): String = when { + raw.length >= 2 && raw.startsWith("\"") && raw.endsWith("\"") -> try { + (json.parseToJsonElement(raw) as? JsonPrimitive)?.content ?: raw.drop(1).dropLast(1) + } catch (_: SerializationException) { + raw.drop(1).dropLast(1) + } + + raw.length >= 2 && raw.startsWith("'") && raw.endsWith("'") -> raw.drop(1).dropLast(1).replace("''", "'") + + else -> raw + } +} diff --git a/app/src/main/java/com/pasich/encly/data/importer/ImportBlocks.kt b/app/src/main/java/com/pasich/encly/data/importer/ImportBlocks.kt new file mode 100644 index 00000000..a2c0f53f --- /dev/null +++ b/app/src/main/java/com/pasich/encly/data/importer/ImportBlocks.kt @@ -0,0 +1,31 @@ +package com.pasich.encly.data.importer + +import com.pasich.encly.domain.model.ItemListBlock +import com.pasich.encly.dynamicBlocks.Block +import com.pasich.encly.dynamicBlocks.BlockType +import kotlinx.coroutines.flow.MutableStateFlow + +/** Builders for the Encly blocks the parsers produce. */ +internal object ImportBlocks { + private val PARAGRAPH_BREAK = Regex("""\n[ \t]*\n\s*""") + private val HEADINGS = listOf(BlockType.H1, BlockType.H2, BlockType.H3, BlockType.H4) + + fun text(value: String): Block = Block.TextBlock(text = MutableStateFlow(value)) + + /** [level] 1-4; deeper levels are H4. */ + fun heading(value: String, level: Int): Block = Block.HBlock( + text = MutableStateFlow(value), + blockType = HEADINGS[level.coerceIn(1, HEADINGS.size) - 1], + ) + + fun quote(value: String): Block = Block.QuoteBlock(text = MutableStateFlow(value)) + + fun list(type: BlockType, items: List): Block = + Block.ListBlock(items = MutableStateFlow(items), blockType = type) + + /** Blank-line separated paragraphs as TEXT blocks; line breaks stay inside their paragraph. */ + fun paragraphs(text: String): List = text.trim().split(PARAGRAPH_BREAK) + .map { it.trimEnd() } + .filter { it.isNotBlank() } + .map(::text) +} diff --git a/app/src/main/java/com/pasich/encly/data/importer/ImportMapper.kt b/app/src/main/java/com/pasich/encly/data/importer/ImportMapper.kt new file mode 100644 index 00000000..622d1456 --- /dev/null +++ b/app/src/main/java/com/pasich/encly/data/importer/ImportMapper.kt @@ -0,0 +1,268 @@ +package com.pasich.encly.data.importer + +import com.pasich.encly.core.backup.BackupNote +import com.pasich.encly.core.backup.BackupPayload +import com.pasich.encly.core.backup.BackupPayloadCodec +import com.pasich.encly.core.backup.BackupTag +import com.pasich.encly.core.serialization.BlockConverter +import com.pasich.encly.data.backup.BackupImporter +import com.pasich.encly.dynamicBlocks.Block +import com.pasich.encly.dynamicBlocks.BlockType +import java.security.MessageDigest + +/** What the user confirms before anything is written: counts only, never content. */ +data class ImportPreview( + val source: ImportSource, + val notes: Int, + val checklists: Int, + val tags: Int, + val trashed: Int, + /** Notes whose uid is already in the vault; filled in once the vault has been looked at. */ + val duplicates: Int = 0, + /** Notes that repeat another note of the same selection. */ + val duplicatesInSelection: Int = 0, + val losses: ImportLosses = ImportLosses(), + /** Encrypted or unsupported entries that were left out. */ + val unsupported: Int = 0, + /** Files that could not be read, were too large, empty or unsafe. */ + val filesSkipped: Int = 0, + /** Archives inside archives, which are not opened. */ + val nestedArchives: Int = 0, +) + +/** A mapped selection: the payload [com.pasich.encly.data.backup.BackupImporter] merges, and its [preview]. */ +class ImportPlan(val payload: BackupPayload, val preview: ImportPreview, val rows: List) { + fun withExisting(existingNoteUids: Set): ImportPlan = ImportPlan( + payload, + preview.copy(duplicates = payload.notes.count { it.uid in existingNoteUids }), + rows.map { it.copy(existing = it.uid in existingNoteUids) }, + ) +} + +/** + * Parsed notes -> the [BackupPayload] the backup MERGE import applies, so an import gets the + * same single transaction and uid skip as a backup. + * + * - uid: SHA-256 of `encly-import/v1||` when the export has a stable id + * (uuid, Joplin id, Keep path + creation time, Markdown path + [contentDigest]), else of the + * normalized title and text. + * The same export imports to the same uids, so a second import adds nothing; two notes with + * equal text but different titles or paths stay two notes. + * - Tags are matched by name against the vault ([com.pasich.encly.data.backup.TagMatch.UID_OR_NAME]). + * Encly has one tag per note, so the first of a note's tags is used. Names are cut to + * [MAX_TAG_NAME] characters, as the tag editor does. + * - A missing creation or edit date becomes the other one, or [now]. + */ +object ImportMapper { + /** The tag editor's limit (presentation TAG_NAME_MAX_LENGTH). */ + private const val MAX_TAG_NAME = 20 + private const val HEX_DIGITS = "0123456789abcdef" + private const val NIBBLE = 4 + private const val LOW_NIBBLE = 0x0f + private val WHITESPACE = Regex("""\s+""") + + internal fun map(parsed: ParsedNotes, source: ImportSource, read: ReadSkips, now: Long): ImportPlan { + val tags = LinkedHashMap() + val tagNames = HashMap() + val uids = HashSet() + val notes = ArrayList() + val rows = ArrayList() + var losses = parsed.extraLosses + var checklists = 0 + var trashed = 0 + var duplicates = 0 + for (note in parsed.notes) { + val uid = uidFor(source, note) + if (!uids.add(uid)) { + duplicates++ + continue + } + val tagUid = note.tags.firstNotNullOfOrNull { tag(it, tags, tagNames) } + val edited = note.edited ?: note.created ?: now + notes += BackupNote( + uid = uid, + title = note.title.trim(), + value = BlockConverter.blocksToJson(note.blocks), + description = "", + date = edited, + dateCreate = note.created ?: edited, + tagUid = tagUid, + isTrash = note.trashed, + ) + rows += ImportReview.row(uid, note, tagUid?.let(tagNames::get), edited) + losses += note.losses + if (note.blocks.any { it is Block.ListBlock && it.blockType == BlockType.LIST_CHECK }) checklists++ + if (note.trashed) trashed++ + } + val usedTagUids = notes.mapNotNullTo(HashSet()) { it.tagUid } + val payload = BackupPayload( + exportedAt = now, + tags = tags.values.filter { it.uid in usedTagUids }.mapIndexed { index, tag -> tag.copy(position = index) }, + notes = notes, + tasks = emptyList(), + ) + BackupPayloadCodec.validate(payload) + val preview = ImportPreview( + source = source, + notes = notes.size, + checklists = checklists, + tags = payload.tags.size, + trashed = trashed, + duplicatesInSelection = duplicates, + losses = losses, + unsupported = parsed.skips.unsupported, + filesSkipped = parsed.skips.files + read.total - read.nestedArchives, + nestedArchives = read.nestedArchives, + ) + return ImportPlan(payload, preview, rows) + } + + private fun tag(raw: String, tags: MutableMap, names: MutableMap): String? { + val name = raw.trim().split(WHITESPACE).filter { it.isNotEmpty() }.joinToString(" ").take(MAX_TAG_NAME).trim() + if (name.isEmpty()) return null + val key = BackupImporter.tagNameKey(name) + val tag = tags.getOrPut(key) { + BackupTag(uid = sha256Hex("encly-import/v1|tag|$key"), name = name, visible = true, position = tags.size) + } + names[tag.uid] = tag.name + return tag.uid + } + + private fun uidFor(source: ImportSource, note: ImportedNote): String { + val key = note.sourceKey?.takeIf { it.isNotBlank() } + val identity = if (key != null) { + "encly-import/v1|${source.name.lowercase()}|$key" + } else { + "encly-import/v1|${source.name.lowercase()}|content|${normalized(note.title)}\n${normalized(textOf(note))}" + } + return sha256Hex(identity) + } + + private fun normalized(text: String): String = text.trim().replace(WHITESPACE, " ").lowercase() + + /** SHA-256 of the normalized [title] and text of [blocks]: for a source key of a note with no id of its own. */ + internal fun contentDigest(title: String, blocks: List): String = + sha256Hex("${normalized(title)}\n${normalized(textOf(blocks))}") + + private fun textOf(note: ImportedNote): String = textOf(note.blocks) + + private fun textOf(blocks: List): String = blocks.joinToString("\n") { block -> + when (block) { + is Block.TextBlock -> block.text.value + is Block.HBlock -> block.text.value + is Block.QuoteBlock -> block.text.value + is Block.ListBlock -> block.items.value.joinToString("\n") { (if (it.isCheck) "[x] " else "") + it.value } + else -> "" + } + } + + private fun sha256Hex(text: String): String { + val digest = MessageDigest.getInstance("SHA-256").digest(text.toByteArray(Charsets.UTF_8)) + return buildString(digest.size * 2) { + digest.forEach { byte -> + val b = byte.toInt() + append(HEX_DIGITS[(b shr NIBBLE) and LOW_NIBBLE]) + append(HEX_DIGITS[b and LOW_NIBBLE]) + } + } + } +} + +/** The outcome of looking at a selection. */ +sealed interface Analysis { + class Ready(val plan: ImportPlan) : Analysis + + data class Encrypted(val source: ImportSource) : Analysis + + /** Files of several apps: the user chooses one with [ImportAnalyzer.analyze]'s `forced`. */ + data object Ambiguous : Analysis + + data class Failed(val error: ImportError) : Analysis +} + +/** Selection -> detection -> parser -> [ImportPlan]. Pure and in memory. */ +object ImportAnalyzer { + + fun analyze( + read: ReadResult, + forced: ImportSource? = null, + limits: ImportLimits = ImportLimits(), + now: Long = System.currentTimeMillis(), + ): Analysis { + val kinds = FormatDetector.classify(read.entries, limits) + val detection = if (forced == null) FormatDetector.detect(kinds) else FormatDetector.validate(forced, kinds) + return when (detection) { + Detection.Ambiguous -> Analysis.Ambiguous + + Detection.Empty -> Analysis.Failed(emptyError(read.skips)) + + Detection.Unsupported -> Analysis.Failed(ImportError.UNSUPPORTED) + + is Detection.Encrypted -> Analysis.Encrypted(detection.source) + + is Detection.Found -> try { + Analysis.Ready(plan(detection.source, read, classified(kinds, forced), limits, now)) + } catch (e: ImportException) { + Analysis.Failed(e.error) + } + } + } + + /** Nothing usable was read: say why, if files were left out on the way (too large, unreadable). */ + private fun emptyError(skips: ReadSkips): ImportError = when { + skips.tooLarge > 0 -> ImportError.TOO_LARGE + skips.unreadable > 0 -> ImportError.UNREADABLE + else -> ImportError.NOTHING_FOUND + } + + /** A Notesnook choice also reads plain text as Notesnook's (a `.txt` export has no front matter to recognise). */ + private fun classified(kinds: List, forced: ImportSource?): List = if (forced == + ImportSource.NOTESNOOK + ) { + kinds.map { if (it == EntryKind.TEXT) EntryKind.NOTESNOOK else it } + } else { + kinds + } + + private fun parse( + source: ImportSource, + entries: List, + limits: ImportLimits, + notesnookFile: (ImportEntry) -> Boolean, + ): ParsedNotes = when (source) { + ImportSource.GOOGLE_KEEP -> KeepParser.parse(entries, limits) + ImportSource.STANDARD_NOTES -> StandardNotesParser.parse(entries, limits) + ImportSource.JOPLIN -> JoplinParser.parse(entries, limits) + ImportSource.NOTESNOOK -> MarkdownNotesParser.parse(entries, limits, notesnook = true, notesnookFile) + ImportSource.MARKDOWN -> MarkdownNotesParser.parse(entries, limits, notesnook = false) + } + + private fun plan( + source: ImportSource, + read: ReadResult, + kinds: List, + limits: ImportLimits, + now: Long, + ): ImportPlan { + val skipped = setOf(EntryKind.IGNORED, EntryKind.STANDARD_NOTES_ENCRYPTED, EntryKind.NOTESNOOK_BACKUP) + // Ignored files still reach the parsers when they are media, so attachments can be counted. + val usable = read.entries.filterIndexed { index, entry -> + kinds[index] !in skipped || + (kinds[index] == EntryKind.IGNORED && !ImportKinds.isHidden(entry) && ImportKinds.isMedia(entry)) + } + val encrypted = kinds.count { it == EntryKind.STANDARD_NOTES_ENCRYPTED || it == EntryKind.NOTESNOOK_BACKUP } + // Notesnook's rules belong to the files recognised as its own, not to the whole selection. + val notesnook = read.entries.filterIndexedTo(HashSet()) { index, _ -> kinds[index] == EntryKind.NOTESNOOK } + val parsed = parse(source, usable, limits, notesnook::contains) + val markdown = source == ImportSource.NOTESNOOK || source == ImportSource.MARKDOWN + // Media the reader passed over without keeping it: the attachments of a Markdown folder. + val withEncrypted = ParsedNotes( + parsed.notes, + parsed.skips.copy(unsupported = parsed.skips.unsupported + encrypted), + if (markdown) parsed.extraLosses + ImportLosses(attachments = read.attachments) else parsed.extraLosses, + ) + val plan = ImportMapper.map(withEncrypted, source, read.skips, now) + if (plan.payload.notes.isEmpty()) throw ImportException(ImportError.NOTHING_FOUND) + return plan + } +} diff --git a/app/src/main/java/com/pasich/encly/data/importer/ImportReader.kt b/app/src/main/java/com/pasich/encly/data/importer/ImportReader.kt new file mode 100644 index 00000000..ceea0094 --- /dev/null +++ b/app/src/main/java/com/pasich/encly/data/importer/ImportReader.kt @@ -0,0 +1,292 @@ +package com.pasich.encly.data.importer + +import java.io.ByteArrayInputStream +import java.io.FilterInputStream +import java.io.IOException +import java.io.InputStream +import java.io.SequenceInputStream + +/** + * One file the user picked, or one found while walking a picked folder. [open] is null if + * unreadable. An [unsafe] file (a folder too deep to walk) is counted as an unsafe path and never opened. + */ +class SourceFile(val path: String, val unsafe: Boolean = false, val open: () -> InputStream?) + +/** Where the files of a selection come from; the Storage Access Framework implementation is [SafImportFiles]. */ +fun interface ImportFileSource { + fun files(): Sequence +} + +/** + * Entries read from a selection, plus what was left out on the way. [attachments] counts the + * media files (images, audio, video, PDFs) that were passed over without being kept. + */ +class ReadResult(val entries: List, val skips: ReadSkips, val attachments: Int = 0) + +/** + * Turns a selection into in-memory [ImportEntry]s, bounded by [ImportLimits]. + * + * Only streams are used (no paths, no seeking, no declared sizes): ZIP through + * [ZipStreamReader], TAR through [TarReader], plain files as they are. Everything counts the + * bytes actually read, so a ZIP bomb stops at the limit with [ImportError.TOO_LARGE]. Unsafe + * paths, links and devices, archives inside archives and oversized files are skipped and counted; + * nothing is ever written to disk. ZIP symbolic links cannot be told apart in a sequential read, + * but they are harmless here: their target path is just a short text entry. + * + * Media and attachments (judged by name) are counted, never kept: a picked file or folder entry + * is not even opened, a TAR member's data is discarded, and a ZIP member is inflated through a + * small buffer because the next header cannot be found otherwise. What it inflates counts against + * [ImportLimits.maxSkippedBytes], not [ImportLimits.maxTotalBytes], so a Takeout full of photos + * fits and a bomb named `photo.jpg` still stops. + */ +class ImportReader(private val limits: ImportLimits = ImportLimits()) { + + fun read(source: ImportFileSource, checkCancelled: () -> Unit = {}): ReadResult = + Session(limits, checkCancelled).run(source) + + // One read of a selection: the counters, the three archive formats and their skips live together. + @Suppress("TooManyFunctions") + private class Session(private val limits: ImportLimits, private val checkCancelled: () -> Unit) { + private val entries = ArrayList() + private var seen = 0 + private var attachments = 0 + private var skippedBytes = 0L + private val scratch = ByteArray(BUFFER) + private var inputBytes = 0L + private var totalBytes = 0L + private var unsafe = 0 + private var nested = 0 + private var tooLarge = 0 + private var unreadable = 0 + + fun run(source: ImportFileSource): ReadResult { + for (file in source.files()) { + checkCancelled() + if (!passedOver(file)) read(file) + } + return ReadResult(entries, ReadSkips(unsafe, nested, tooLarge, unreadable), attachments) + } + + /** A file that is counted without being opened: one known to be unsafe, or media. */ + private fun passedOver(file: SourceFile): Boolean { + val pass = file.unsafe || ImportKinds.isMedia(file.path) + if (pass) { + countEntry() + if (file.unsafe) unsafe++ else attachment(file.path) + } + return pass + } + + private fun read(file: SourceFile) { + val stream = try { + file.open() + } catch (_: IOException) { + null + } catch (_: SecurityException) { + null + } + if (stream == null) { + unreadable++ + return + } + try { + stream.use { process(file.path, Counting(it)) } + } catch (_: IOException) { + unreadable++ + } catch (_: IllegalArgumentException) { + // A ZIP entry name that is not valid text. + unreadable++ + } + } + + private fun process(path: String, input: InputStream) { + val head = readHead(input) + val whole = SequenceInputStream(ByteArrayInputStream(head), input) + when { + isZip(head) -> readZip(whole) + + TarReader.looksLikeTar(head) -> readTar(whole) + + else -> { + countEntry() + accept(path, whole) + } + } + } + + private fun countEntry() { + if (++seen > limits.maxEntries) throw ImportException(ImportError.TOO_LARGE) + } + + /** A media file that is not kept: an attachment, unless it sits in a hidden folder or has an unsafe path. */ + private fun attachment(rawPath: String) { + val path = safePath(rawPath, limits.maxPathDepth) + when { + path == null -> unsafe++ + !ImportKinds.isHidden(path) -> attachments++ + } + } + + /** Up to [TarReader.BLOCK] bytes of [input]: enough to tell a ZIP or TAR from a plain file. */ + private fun readHead(input: InputStream): ByteArray { + val head = ByteArray(TarReader.BLOCK) + var read = 0 + while (read < head.size) { + val n = input.read(head, read, head.size - read) + if (n < 0) break + read += n + } + return head.copyOf(read) + } + + private fun readZip(input: InputStream) { + val zip = ZipStreamReader(input) + while (true) { + checkCancelled() + val entry = zip.next() ?: return + countEntry() + when { + entry.isDirectory -> skipDirectory(zip.data()) + + ImportKinds.isMedia(entry.name) -> { + attachment(entry.name) + drain(zip.data()) + } + + else -> accept(entry.name, zip.data()) + } + } + } + + private fun readTar(input: InputStream) { + val tar = TarReader(input) + while (true) { + checkCancelled() + val header = tar.next() ?: return + countEntry() + when (header.type) { + TarReader.DIRECTORY -> Unit + + // The reader discards the data of a member that is not read. + TarReader.REGULAR, TarReader.REGULAR_OLD -> { + val media = ImportKinds.isMedia(header.path) + if (media) attachment(header.path) else accept(header.path, tar.data()) + } + + // Links, devices, FIFOs: never followed. + else -> unsafe++ + } + } + } + + /** Reads [input] (always to its end, so the counters see everything) and keeps it if it is usable. */ + private fun accept(rawPath: String, input: InputStream) { + val path = safePath(rawPath, limits.maxPathDepth) + val cap = if (path != null && isContainerName(path)) limits.maxContainerBytes else limits.maxEntryBytes + val bytes = readBounded(input, cap) + when { + path == null -> unsafe++ + bytes == null -> tooLarge++ + looksLikeArchive(bytes) -> nested++ + else -> entries += ImportEntry(path, bytes) + } + } + + /** + * A ZIP directory entry: it counts as an entry and whatever data it carries is inflated + * against the total, so many directories with data cannot inflate past the limits. + */ + private fun skipDirectory(input: InputStream) { + // A directory carries no data; one that does is not a file to import. + if (readBounded(input, 0) == null) unsafe++ + } + + /** Reads [input] to its end and throws it away, counting what it inflates against the skipped-data cap. */ + private fun drain(input: InputStream) { + while (true) { + val n = input.read(scratch) + if (n < 0) return + skippedBytes += n + if (skippedBytes > limits.maxSkippedBytes) throw ImportException(ImportError.TOO_LARGE) + checkCancelled() + } + } + + /** The bytes of [input], or null if it is longer than [cap]; the rest is still read and counted. */ + private fun readBounded(input: InputStream, cap: Int): ByteArray? { + val out = java.io.ByteArrayOutputStream() + val buffer = ByteArray(BUFFER) + var over = false + while (true) { + val n = input.read(buffer) + if (n < 0) break + totalBytes += n + if (totalBytes > limits.maxTotalBytes) throw ImportException(ImportError.TOO_LARGE) + if (!over) { + if (out.size() + n > cap) over = true else out.write(buffer, 0, n) + } + checkCancelled() + } + return if (over) null else out.toByteArray() + } + + private inner class Counting(input: InputStream) : FilterInputStream(input) { + override fun read(): Int { + val b = super.read() + if (b >= 0) count(1) + return b + } + + override fun read(b: ByteArray, off: Int, len: Int): Int { + val n = super.read(b, off, len) + if (n > 0) count(n.toLong()) + return n + } + + override fun markSupported(): Boolean = false + + private fun count(n: Long) { + inputBytes += n + if (inputBytes > limits.maxInputBytes) throw ImportException(ImportError.TOO_LARGE) + } + } + } + + companion object { + private const val BUFFER = 16 * 1024 + private const val BACKUP_FILE_PREFIX = "Standard Notes Backup and Import File" + + /** Files that legitimately hold many notes in one: JSON exports. */ + internal fun isContainerName(path: String): Boolean { + val name = path.substringAfterLast('/') + return name.endsWith(".json", ignoreCase = true) || name.startsWith(BACKUP_FILE_PREFIX) + } + + /** + * [raw] as a relative path with `.` and empty segments removed and `\` read as `/` (old + * Windows tools write it), or null if it is unsafe: absolute, a drive (`C:` as the first + * segment), `..` or NUL, or deeper than [maxDepth]. + */ + fun safePath(raw: String, maxDepth: Int = ImportLimits.MAX_PATH_DEPTH): String? { + val normalized = raw.replace('\\', '/') + val segments = normalized.split('/').filter { it.isNotEmpty() && it != "." } + val unsafe = '\u0000' in raw || normalized.startsWith("/") || segments.isEmpty() || + segments.size > maxDepth || ".." in segments || DRIVE.matches(segments.first()) + return if (unsafe) null else segments.joinToString("/") + } + + private val DRIVE = Regex("^[A-Za-z]:$") + + private fun isZip(head: ByteArray): Boolean = head.size >= ZIP_MAGIC.size && + ZIP_MAGIC.indices.all { head[it] == ZIP_MAGIC[it] } + + /** ZIP, TAR and gzip content: archives inside archives are not expanded. */ + private fun looksLikeArchive(bytes: ByteArray): Boolean = isZip(bytes) || + (bytes.size >= 2 && bytes[0] == GZIP_0 && bytes[1] == GZIP_1) || + TarReader.looksLikeTar(bytes) + + private val ZIP_MAGIC = byteArrayOf(0x50, 0x4b, 0x03, 0x04) + private const val GZIP_0: Byte = 0x1f + private const val GZIP_1: Byte = 0x8b.toByte() + } +} diff --git a/app/src/main/java/com/pasich/encly/data/importer/ImportReview.kt b/app/src/main/java/com/pasich/encly/data/importer/ImportReview.kt new file mode 100644 index 00000000..e67442a3 --- /dev/null +++ b/app/src/main/java/com/pasich/encly/data/importer/ImportReview.kt @@ -0,0 +1,93 @@ +package com.pasich.encly.data.importer + +import com.pasich.encly.core.backup.BackupNote +import com.pasich.encly.core.backup.BackupPayload +import com.pasich.encly.core.backup.BackupPayloadCodec +import com.pasich.encly.core.serialization.BlockConverter +import com.pasich.encly.dynamicBlocks.Block +import com.pasich.encly.dynamicBlocks.BlockType + +/** + * One note of the review list. Held in memory only, for the open vault; it is dropped on cancel, + * on finish and when the vault locks, and is never saved in instance state. + */ +data class ReviewNote( + val uid: String, + val title: String, + /** About one line of the first text, no markup. */ + val snippet: String, + val hasChecklist: Boolean, + val tag: String?, + val trashed: Boolean, + val date: Long, + /** A note with this uid is already in the vault, so importing it would add nothing. */ + val existing: Boolean = false, +) + +/** Review-list helpers: the row of a note, its read-only text and the payload of a selection. */ +object ImportReview { + private const val SNIPPET_CHARS = 80 + private const val BODY_CHARS = 4000 + private val WHITESPACE = Regex("""\s+""") + + internal fun row(uid: String, note: ImportedNote, tag: String?, date: Long) = ReviewNote( + uid = uid, + title = note.title.trim(), + snippet = snippet(note.blocks), + hasChecklist = note.blocks.any { it is Block.ListBlock && it.blockType == BlockType.LIST_CHECK }, + tag = tag, + trashed = note.trashed, + date = date, + ) + + private fun snippet(blocks: List): String { + val first = blocks.firstNotNullOfOrNull { text(it).takeIf(String::isNotBlank) }.orEmpty() + return first.trim().replace(WHITESPACE, " ").take(SNIPPET_CHARS) + } + + private fun text(block: Block): String = when (block) { + is Block.TextBlock -> block.text.value + is Block.HBlock -> block.text.value + is Block.QuoteBlock -> block.text.value + is Block.ListBlock -> block.items.value.firstOrNull()?.value.orEmpty() + else -> "" + } + + /** The note as plain text for the inline read-only preview, cut at a few thousand characters. */ + fun body(note: BackupNote): String { + val blocks = BlockConverter.jsonToBlocksOrNull(note.value).orEmpty() + val text = blocks.joinToString("\n\n") { render(it) }.trim() + return if (text.length > BODY_CHARS) text.take(BODY_CHARS) + "…" else text + } + + private fun render(block: Block): String = when (block) { + is Block.TextBlock -> block.text.value + + is Block.HBlock -> block.text.value + + is Block.QuoteBlock -> block.text.value.lines().joinToString("\n") { "> $it" } + + is Block.SeparatorBlock -> "———" + + is Block.LinkBlock -> block.block.value.url + + is Block.ListBlock -> block.items.value.withIndex().joinToString("\n") { (index, item) -> + when (block.blockType) { + BlockType.LIST_CHECK -> (if (item.isCheck) "☑ " else "☐ ") + item.value + BlockType.LIST_NUMBER -> "${index + 1}. ${item.value}" + else -> "• ${item.value}" + } + } + } + + /** + * [payload] reduced to the notes in [selected] and the tags they use, renumbered and validated + * again: what the one MERGE transaction then writes. + */ + fun selection(payload: BackupPayload, selected: Set): BackupPayload { + val notes = payload.notes.filter { it.uid in selected } + val used = notes.mapNotNullTo(HashSet()) { it.tagUid } + val tags = payload.tags.filter { it.uid in used }.mapIndexed { index, tag -> tag.copy(position = index) } + return payload.copy(tags = tags, notes = notes).also(BackupPayloadCodec::validate) + } +} diff --git a/app/src/main/java/com/pasich/encly/data/importer/ImportText.kt b/app/src/main/java/com/pasich/encly/data/importer/ImportText.kt new file mode 100644 index 00000000..3d4ee431 --- /dev/null +++ b/app/src/main/java/com/pasich/encly/data/importer/ImportText.kt @@ -0,0 +1,103 @@ +package com.pasich.encly.data.importer + +import com.pasich.encly.dynamicBlocks.hasContent +import kotlinx.serialization.SerializationException +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.contentOrNull +import java.nio.ByteBuffer +import java.nio.charset.CharacterCodingException +import java.nio.charset.CodingErrorAction + +/** Small readers shared by the parsers. Nothing here keeps or logs content. */ +internal object ImportText { + private const val BOM_CODE = 0xFEFF + + private val json = Json { isLenient = false } + + /** UTF-8 text of [bytes] without a byte order mark and with `\n` line ends; null if it is not valid UTF-8. */ + fun utf8(bytes: ByteArray): String? = try { + val decoder = Charsets.UTF_8.newDecoder() + .onMalformedInput(CodingErrorAction.REPORT) + .onUnmappableCharacter(CodingErrorAction.REPORT) + decoder.decode(ByteBuffer.wrap(bytes)).toString().removePrefix(Char(BOM_CODE).toString()).replace("\r\n", "\n") + .replace('\r', '\n') + } catch (_: CharacterCodingException) { + null + } + + /** The JSON of [text], or null if it is malformed or nested deeper than [maxDepth]. */ + fun json(text: String, maxDepth: Int = ImportLimits.MAX_JSON_DEPTH): JsonElement? { + if (nestingDepth(text) > maxDepth) return null + return try { + json.parseToJsonElement(text) + } catch (_: SerializationException) { + null + } catch (_: IllegalArgumentException) { + null + } + } + + /** The deepest `[` / `{` nesting in [text], brackets inside strings not counted. */ + private fun nestingDepth(text: String): Int { + var depth = 0 + var deepest = 0 + var inString = false + var escaped = false + for (c in text) { + when { + escaped -> escaped = false + inString && c == '\\' -> escaped = true + c == '"' -> inString = !inString + inString -> Unit + c == '[' || c == '{' -> deepest = maxOf(deepest, ++depth) + c == ']' || c == '}' -> depth-- + } + } + return deepest + } + + fun string(element: JsonElement?): String? = (element as? JsonPrimitive)?.takeIf { it.isString }?.contentOrNull + + fun obj(element: JsonElement?): JsonObject? = element as? JsonObject + + fun long(element: JsonElement?): Long? = (element as? JsonPrimitive)?.takeIf { !it.isString }?.contentOrNull + ?.toLongOrNull() + + fun bool(element: JsonElement?): Boolean? = (element as? JsonPrimitive)?.takeIf { !it.isString }?.contentOrNull + ?.toBooleanStrictOrNull() + + /** The first line of [text] without surrounding blanks. */ + fun firstLine(text: String): String = text.lineSequence().firstOrNull().orEmpty().trim() + + /** A list of names as distinct, trimmed, non-blank entries in order. */ + fun names(values: List): List = values.map { it.trim().removePrefix("#").trim() } + .filter { it.isNotEmpty() } + .distinctBy { it.lowercase() } +} + +/** What a parser collects: notes, and the entries it had to leave out. */ +internal class ParseContext(private val limits: ImportLimits) { + private val notes = ArrayList() + var unsupported = 0 + var unreadable = 0 + var empty = 0 + var extraLosses = ImportLosses() + + /** Decodes [entry] as UTF-8; an invalid file is counted as unreadable and skipped. */ + fun text(entry: ImportEntry): String? = ImportText.utf8(entry.bytes).also { if (it == null) unreadable++ } + + fun add(note: ImportedNote) { + if (note.title.isBlank() && note.blocks.none { it.hasContent() }) { + empty++ + return + } + if (notes.size >= limits.maxNotes) throw ImportException(ImportError.TOO_LARGE) + notes += note + } + + fun result() = + ParsedNotes(notes, ImportSkips(unsupported = unsupported, unreadable = unreadable, empty = empty), extraLosses) +} diff --git a/app/src/main/java/com/pasich/encly/data/importer/ImportTime.kt b/app/src/main/java/com/pasich/encly/data/importer/ImportTime.kt new file mode 100644 index 00000000..85bc34da --- /dev/null +++ b/app/src/main/java/com/pasich/encly/data/importer/ImportTime.kt @@ -0,0 +1,115 @@ +package com.pasich.encly.data.importer + +import java.time.DateTimeException +import java.time.Instant +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.OffsetDateTime +import java.time.ZoneId +import java.time.format.DateTimeParseException + +/** + * Timestamps of other apps as Encly milliseconds; anything missing, zero, negative, absurd, + * unparseable or ambiguous is null (a wrong date is worse than none). A date without an offset is + * taken in the phone's time zone: it is a local time, and the export is imported on the same phone + * as a rule. + * + * Known forms: ISO-8601 (with or without offset), `2026-10-14 19:32:53Z`, epoch seconds or + * milliseconds, and the user-selectable formats of Notesnook: `dd-MM-yyyy hh:mm a`, + * `MM-dd-yyyy hh:mm a`, `yyyy-MM-dd hh:mm a` and their 24-hour forms, with `-`, `/` or `.`. When day + * and month could be swapped (both 12 or less and different), the date is ambiguous. + */ +internal object ImportTime { + /** Year 3000 in milliseconds: anything later is a corrupt timestamp. */ + private const val MAX_MILLIS = 32_503_680_000_000L + private const val MICROS_PER_MILLI = 1000L + + /** Epoch values with at least this many digits are milliseconds, shorter ones seconds. */ + private const val MILLIS_DIGITS = 12 + private const val MILLIS_PER_SECOND = 1000L + private const val MONTHS = 12 + private const val HALF_DAY = 12 + + /** `2026-10-14 19:32:53Z`, how Joplin writes dates in front matter. */ + private val SPACE_SEPARATED = Regex("""^(\d{4}-\d{2}-\d{2}) (\d.*)$""") + + private const val CLOCK = """(?:[ ,T]+(\d{1,2}):(\d{2})(?::(\d{2}))?\s*([AaPp][Mm])?)""" + private val DAY_FIRST = Regex("""^(\d{1,2})[-./](\d{1,2})[-./](\d{4})$CLOCK?$""") + private val YEAR_FIRST = Regex("""^(\d{4})[-./](\d{1,2})[-./](\d{1,2})$CLOCK$""") + + private val ISO_FORMS: List<(String) -> Long> = listOf( + { Instant.parse(it).toEpochMilli() }, + { OffsetDateTime.parse(it).toInstant().toEpochMilli() }, + { LocalDateTime.parse(it).atZone(ZoneId.systemDefault()).toInstant().toEpochMilli() }, + { LocalDate.parse(it).atStartOfDay(ZoneId.systemDefault()).toInstant().toEpochMilli() }, + ) + + /** Microseconds since the epoch as milliseconds; null if missing, not positive or absurdly large. */ + fun microsToMillis(micros: Long?): Long? = micros?.div(MICROS_PER_MILLI)?.takeIf { it in 1..MAX_MILLIS } + + fun parse(raw: String?): Long? { + val text = raw?.trim().orEmpty().replace(SPACE_SEPARATED, "$1T$2") + val millis = when { + text.isEmpty() -> null + + text.all { it.isDigit() } -> + text.toLongOrNull()?.let { if (text.length >= MILLIS_DIGITS) it else it * MILLIS_PER_SECOND } + + else -> localForm(text) ?: iso(text) + } + return millis?.takeIf { it in 1..MAX_MILLIS } + } + + private fun iso(text: String): Long? = ISO_FORMS.firstNotNullOfOrNull { form -> + try { + form(text) + } catch (_: DateTimeParseException) { + null + } + } + + /** `14-10-2026 10:48 PM`, `10/14/2026 22:48`, `2026-10-14 10:48 PM`: null if absent or ambiguous. */ + private fun localForm(text: String): Long? { + val dayFirst = DAY_FIRST.matchEntire(text)?.groupValues + val yearFirst = YEAR_FIRST.matchEntire(text)?.groupValues + return when { + dayFirst != null -> dayAndMonth(dayFirst[A].toInt(), dayFirst[B].toInt()) + ?.let { (day, month) -> millis(dayFirst[C].toInt(), month, day, dayFirst) } + + yearFirst != null -> millis(yearFirst[A].toInt(), yearFirst[B].toInt(), yearFirst[C].toInt(), yearFirst) + + else -> null + } + } + + /** (day, month) of two numbers in unknown order, or null if either order is possible. */ + private fun dayAndMonth(first: Int, second: Int): Pair? = when { + first > MONTHS && second <= MONTHS -> first to second + second > MONTHS && first <= MONTHS -> second to first + first == second -> first to second + else -> null + } + + /** [clock]: regex groups 4..7 are hour, minute, second and am/pm; any may be empty. */ + private fun millis(year: Int, month: Int, day: Int, clock: List): Long? = try { + val hour = clock[HOUR].toIntOrNull() ?: 0 + val hour24 = when (clock[MERIDIEM].lowercase()) { + "am" -> hour % HALF_DAY + "pm" -> hour % HALF_DAY + HALF_DAY + else -> hour + } + LocalDateTime.of(year, month, day, hour24, clock[MINUTE].toIntOrNull() ?: 0, clock[SECOND].toIntOrNull() ?: 0) + .atZone(ZoneId.systemDefault()).toInstant().toEpochMilli() + } catch (_: DateTimeException) { + null + } + + // Regex groups: the date parts in the order the pattern has them, then the clock. + private const val A = 1 + private const val B = 2 + private const val C = 3 + private const val HOUR = 4 + private const val MINUTE = 5 + private const val SECOND = 6 + private const val MERIDIEM = 7 +} diff --git a/app/src/main/java/com/pasich/encly/data/importer/ImportTypes.kt b/app/src/main/java/com/pasich/encly/data/importer/ImportTypes.kt new file mode 100644 index 00000000..ce97b006 --- /dev/null +++ b/app/src/main/java/com/pasich/encly/data/importer/ImportTypes.kt @@ -0,0 +1,171 @@ +package com.pasich.encly.data.importer + +import androidx.annotation.StringRes +import com.pasich.encly.R +import com.pasich.encly.dynamicBlocks.Block + +/** + * The apps Encly can import from. [formats], [howTo] and [losses] are string resources shown on + * the Import notes page; a source is listed only once its parser and tests exist. + */ +enum class ImportSource( + @get:StringRes val title: Int, + @get:StringRes val formats: Int, + @get:StringRes val howTo: Int, + @get:StringRes val losses: Int, +) { + GOOGLE_KEEP( + R.string.import_keep_name, + R.string.import_keep_formats, + R.string.import_keep_howto, + R.string.import_keep_losses, + ), + STANDARD_NOTES( + R.string.import_sn_name, + R.string.import_sn_formats, + R.string.import_sn_howto, + R.string.import_sn_losses, + ), + JOPLIN( + R.string.import_joplin_name, + R.string.import_joplin_formats, + R.string.import_joplin_howto, + R.string.import_joplin_losses, + ), + NOTESNOOK( + R.string.import_nn_name, + R.string.import_nn_formats, + R.string.import_nn_howto, + R.string.import_nn_losses, + ), + MARKDOWN( + R.string.import_md_name, + R.string.import_md_formats, + R.string.import_md_howto, + R.string.import_md_losses, + ), +} + +/** Why an import stops before anything is written. */ +enum class ImportError { + /** A limit of [ImportLimits] was exceeded. */ + TOO_LARGE, + + /** Files were read, but none is a supported export. */ + UNSUPPORTED, + + /** Nothing readable was selected. */ + NOTHING_FOUND, + + /** The selection could not be opened or read. */ + UNREADABLE, +} + +/** Carries an [ImportError]; the message never quotes content. */ +class ImportException(val error: ImportError) : Exception(error.name) + +/** + * Every limit is enforced on the bytes actually read, never on a size an archive header claims, + * so a ZIP bomb stops at the limit. + */ +data class ImportLimits( + /** Bytes read from the selected files in total (compressed bytes count as read); room for a Takeout of photos. */ + val maxInputBytes: Long = MAX_INPUT_BYTES, + /** Bytes after decompression, over every entry that is kept or looked at (oversized ones included). */ + val maxTotalBytes: Long = MAX_TOTAL_BYTES, + /** Bytes inflated and thrown away: media and attachments in a ZIP, which are counted, never kept. */ + val maxSkippedBytes: Long = MAX_SKIPPED_BYTES, + /** One note file. */ + val maxEntryBytes: Int = MAX_ENTRY_BYTES, + /** A JSON export that holds many notes at once (Standard Notes). */ + val maxContainerBytes: Int = MAX_CONTAINER_BYTES, + val maxEntries: Int = MAX_ENTRIES, + val maxNotes: Int = MAX_NOTES, + val maxPathDepth: Int = MAX_PATH_DEPTH, + /** JSON nesting; the tree parser recurses per level. */ + val maxJsonDepth: Int = MAX_JSON_DEPTH, +) { + companion object { + private const val MIB = 1024 * 1024 + const val MAX_TOTAL_BYTES = 64L * MIB + const val MAX_INPUT_BYTES = 2048L * MIB + const val MAX_SKIPPED_BYTES = 2048L * MIB + const val MAX_ENTRY_BYTES = 4 * MIB + const val MAX_CONTAINER_BYTES = 16 * MIB + const val MAX_ENTRIES = 20_000 + const val MAX_NOTES = 20_000 + const val MAX_PATH_DEPTH = 16 + const val MAX_JSON_DEPTH = 64 + } +} + +/** One file of the selection (or one archive member), fully in memory and never on disk. */ +class ImportEntry(val path: String, val bytes: ByteArray) { + val name: String get() = path.substringAfterLast('/') + val extension: String get() = name.substringAfterLast('.', "").lowercase() +} + +/** Files the reading layer left out, by reason. */ +data class ReadSkips( + val unsafePaths: Int = 0, + val nestedArchives: Int = 0, + val tooLarge: Int = 0, + val unreadable: Int = 0, +) { + val total: Int get() = unsafePaths + nestedArchives + tooLarge + unreadable +} + +/** What the parsers could not bring over, counted over the whole selection. */ +data class ImportLosses( + val attachments: Int = 0, + val pinned: Int = 0, + val archived: Int = 0, + val colors: Int = 0, + /** Labels beyond the first: Encly has one tag per note. */ + val extraTags: Int = 0, + /** Joplin to-do notes arrive as ordinary notes. */ + val todos: Int = 0, +) { + operator fun plus(other: ImportLosses) = ImportLosses( + attachments + other.attachments, + pinned + other.pinned, + archived + other.archived, + colors + other.colors, + extraTags + other.extraTags, + todos + other.todos, + ) + + val any: Boolean get() = this != ImportLosses() +} + +/** Entries the parsers did not use. [unsupported] includes encrypted items. */ +data class ImportSkips( + val unsupported: Int = 0, + val unreadable: Int = 0, + val empty: Int = 0, + val duplicatesInSelection: Int = 0, +) { + val files: Int get() = unreadable + empty +} + +/** A note as read from another app, before it is mapped to Encly's records. */ +data class ImportedNote( + /** Stable id inside the source (uuid, path, ...) for a repeatable uid; null when there is none. */ + val sourceKey: String?, + val title: String, + val blocks: List, + /** Tag names in order of preference; Encly keeps the first. */ + val tags: List = emptyList(), + val created: Long? = null, + val edited: Long? = null, + val trashed: Boolean = false, + val losses: ImportLosses = ImportLosses(), +) + +/** Parser output. */ +class ParsedNotes( + val notes: List, + val skips: ImportSkips, + /** Losses that belong to no single note (attachment files, Joplin resources). */ + val extraLosses: ImportLosses = ImportLosses(), +) diff --git a/app/src/main/java/com/pasich/encly/data/importer/JoplinParser.kt b/app/src/main/java/com/pasich/encly/data/importer/JoplinParser.kt new file mode 100644 index 00000000..f0fd8982 --- /dev/null +++ b/app/src/main/java/com/pasich/encly/data/importer/JoplinParser.kt @@ -0,0 +1,98 @@ +package com.pasich.encly.data.importer + +/** + * Joplin RAW / JEX: one `.md` per item, `title`, a blank line, the body, a blank line, then + * `key: value` metadata ending with `type_`. Types: 1 note, 2 notebook, 4 resource, 5 tag, + * 6 note-tag link. Joplin's own *Markdown* exports have no metadata block and are read as + * generic Markdown instead. + * + * A note gets its explicit Joplin tags (alphabetical, the first is kept), else its notebook's + * name. To-do notes arrive as ordinary notes and are counted. Resources (attachments) stay behind. + */ +internal object JoplinParser { + private const val TYPE_NOTE = 1 + private const val TYPE_FOLDER = 2 + private const val TYPE_RESOURCE = 4 + private const val TYPE_TAG = 5 + private const val TYPE_NOTE_TAG = 6 + private val ID = Regex("""[0-9a-f]{32}""") + private val PROPERTY = Regex("""^([A-Za-z_]+):(?: (.*))?$""") + + class Item(val id: String, val type: Int, val head: String, val props: Map) { + val title: String get() = head.lineSequence().firstOrNull().orEmpty().trim() + val body: String get() = head.substringAfter('\n', "").trim('\n') + } + + /** [text] as a Joplin item, or null if it has no metadata block with an `id` and a `type_`. */ + fun item(text: String): Item? { + val trimmed = text.trimEnd() + val split = trimmed.lastIndexOf("\n\n") + val meta = if (split < 0) trimmed else trimmed.substring(split + 2) + val head = if (split < 0) "" else trimmed.substring(0, split) + val props = properties(meta) + val id = props?.get("id")?.takeIf { ID.matches(it) } + val type = props?.get("type_")?.toIntOrNull() + return if (props != null && id != null && type != null) Item(id, type, head, props) else null + } + + /** The `key: value` lines of [meta], or null if any line is something else. */ + private fun properties(meta: String): Map? { + val lines = meta.lines().map { PROPERTY.matchEntire(it) } + return if (lines.any { it == null }) { + null + } else { + lines.filterNotNull().associate { + it.groupValues[1] to + it.groupValues[2] + } + } + } + + fun parse(entries: List, limits: ImportLimits): ParsedNotes { + val context = ParseContext(limits) + val items = ArrayList() + for (entry in entries.filter { it.extension == "md" }) { + val text = context.text(entry) + val item = text?.let(::item) + when { + text == null -> Unit + item == null || item.props["encryption_applied"] == "1" -> context.unsupported++ + else -> items += item + } + } + val folders = items.filter { it.type == TYPE_FOLDER }.associate { it.id to it.title } + val tagsByNote = tagsByNote(items) + context.extraLosses = ImportLosses(attachments = items.count { it.type == TYPE_RESOURCE }) + for (note in items.filter { it.type == TYPE_NOTE }) { + val explicit = ImportText.names(tagsByNote[note.id].orEmpty().sortedBy { it.lowercase() }) + val notebook = folders[note.props["parent_id"]]?.let { ImportText.names(listOf(it)) }.orEmpty() + context.add(note(note, explicit.ifEmpty { notebook }, explicit.size)) + } + return context.result() + } + + /** Note id -> names of the tags linked to it by `note_tag` items. */ + private fun tagsByNote(items: List): Map> { + val tags = items.filter { it.type == TYPE_TAG }.associate { it.id to it.title } + return items.filter { it.type == TYPE_NOTE_TAG } + .mapNotNull { link -> tags[link.props["tag_id"]]?.let { link.props["note_id"].orEmpty() to it } } + .groupBy({ it.first }, { it.second }) + } + + private fun note(note: Item, tags: List, explicitCount: Int) = ImportedNote( + sourceKey = note.id, + title = note.title, + blocks = MarkdownBlockMapper.toBlocks(note.body), + tags = tags, + created = time(note, "user_created_time", "created_time"), + edited = time(note, "user_updated_time", "updated_time"), + trashed = time(note, "deleted_time") != null, + losses = ImportLosses( + extraTags = (explicitCount - 1).coerceAtLeast(0), + todos = if (note.props["is_todo"] == "1") 1 else 0, + ), + ) + + private fun time(item: Item, vararg keys: String): Long? = + keys.firstNotNullOfOrNull { ImportTime.parse(item.props[it]) } +} diff --git a/app/src/main/java/com/pasich/encly/data/importer/KeepParser.kt b/app/src/main/java/com/pasich/encly/data/importer/KeepParser.kt new file mode 100644 index 00000000..5765993d --- /dev/null +++ b/app/src/main/java/com/pasich/encly/data/importer/KeepParser.kt @@ -0,0 +1,87 @@ +package com.pasich.encly.data.importer + +import com.pasich.encly.domain.model.ItemListBlock +import com.pasich.encly.dynamicBlocks.Block +import com.pasich.encly.dynamicBlocks.BlockType +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject + +/** + * Google Keep (Takeout): one JSON object per note, the `.json` files of `Takeout/Keep` in the ZIP, or loose + * files. Keep has no stable id, so a note is identified by its file name and creation time (not its + * folder, so the Takeout ZIP and the loose files give the same note). + * + * Brought over: title, text (paragraphs), checklist items with their state (`listContent`), + * the first label as the tag, trash state, timestamps (microseconds). Counted as losses: + * attachments, pinned, archived, colors, further labels. HTML duplicates and media files are + * ignored. + */ +internal object KeepParser { + private const val CREATED = "createdTimestampUsec" + private const val EDITED = "userEditedTimestampUsec" + private const val DEFAULT_COLOR = "DEFAULT" + + fun isNote(element: JsonElement?): JsonObject? { + val note = ImportText.obj(element) ?: return null + return note.takeIf { ImportText.long(it[CREATED]) != null && ImportText.long(it[EDITED]) != null } + } + + fun parse(entries: List, limits: ImportLimits): ParsedNotes { + val context = ParseContext(limits) + for (entry in entries.filter { it.extension.equals("json", ignoreCase = true) }) { + val text = context.text(entry) + val note = text?.let { isNote(ImportText.json(it, limits.maxJsonDepth)) } + when { + text == null -> Unit + note == null -> context.unsupported++ + else -> context.add(note(entry.name, note)) + } + } + return context.result() + } + + private fun note(path: String, json: JsonObject): ImportedNote { + val created = ImportText.long(json[CREATED]) + val labels = (json["labels"] as? JsonArray).orEmpty() + .mapNotNull { ImportText.string(ImportText.obj(it)?.get("name")) } + val tags = ImportText.names(labels) + val color = ImportText.string(json["color"]) + return ImportedNote( + sourceKey = "$path|$created", + title = ImportText.string(json["title"]).orEmpty().trim(), + blocks = blocks(json), + tags = tags, + created = ImportTime.microsToMillis(created), + edited = ImportTime.microsToMillis(ImportText.long(json[EDITED])), + trashed = ImportText.bool(json["isTrashed"]) == true, + losses = ImportLosses( + attachments = (json["attachments"] as? JsonArray)?.size ?: 0, + pinned = if (ImportText.bool(json["isPinned"]) == true) 1 else 0, + archived = if (ImportText.bool(json["isArchived"]) == true) 1 else 0, + colors = if (color != null && !color.equals(DEFAULT_COLOR, ignoreCase = true)) 1 else 0, + extraTags = (tags.size - 1).coerceAtLeast(0), + ), + ) + } + + private fun blocks(json: JsonObject): List { + val blocks = ArrayList() + ImportText.string(json["textContent"])?.let { blocks += ImportBlocks.paragraphs(it.replace("\r\n", "\n")) } + val items = (json["listContent"] as? JsonArray).orEmpty().mapNotNull { element -> + val item = ImportText.obj(element) + val value = ImportText.string(item?.get("text"))?.trim().orEmpty() + if (value.isEmpty()) { + null + } else { + ItemListBlock( + value, + isCheck = + ImportText.bool(item?.get("isChecked")) == true, + ) + } + } + if (items.isNotEmpty()) blocks += ImportBlocks.list(BlockType.LIST_CHECK, items) + return blocks + } +} diff --git a/app/src/main/java/com/pasich/encly/data/importer/LexicalBlocks.kt b/app/src/main/java/com/pasich/encly/data/importer/LexicalBlocks.kt new file mode 100644 index 00000000..8498c8db --- /dev/null +++ b/app/src/main/java/com/pasich/encly/data/importer/LexicalBlocks.kt @@ -0,0 +1,101 @@ +package com.pasich.encly.data.importer + +import com.pasich.encly.domain.model.ItemListBlock +import com.pasich.encly.dynamicBlocks.Block +import com.pasich.encly.dynamicBlocks.BlockType +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonObject + +/** + * A Standard Notes Super note: Lexical JSON read for its text, in order. Paragraphs, headings, + * quotes, code, rules and (flattened) lists become blocks; a table row is one line; any other node + * contributes its text, a run of inline text staying one paragraph. + */ +internal class LexicalBlocks private constructor() { + private val blocks = ArrayList() + + private fun add(text: String, build: (String) -> Block) { + if (text.isNotBlank()) blocks += build(text) + } + + private fun block(node: JsonObject, depth: Int) { + if (depth > MAX_DEPTH) return + when (ImportText.string(node["type"])) { + "paragraph" -> add(inline(node), ImportBlocks::text) + "heading" -> add(inline(node)) { ImportBlocks.heading(it, headingLevel(node)) } + "quote" -> add(inline(node), ImportBlocks::quote) + "code" -> add(inline(node), ImportBlocks::text) + "tablerow" -> add(children(node).joinToString(" | ") { inline(it).trim() }, ImportBlocks::text) + "horizontalrule" -> blocks += Block.SeparatorBlock() + "list" -> list(node, depth) + else -> other(node, depth) + } + } + + private fun other(node: JsonObject, depth: Int) { + val children = children(node) + when { + children.isEmpty() -> add(ImportText.string(node["text"]).orEmpty(), ImportBlocks::text) + + // Text and line breaks directly inside (a collapsible's title, ...) are one paragraph. + children.any { ImportText.string(it["type"]) in INLINE_TYPES } -> add(inline(node), ImportBlocks::text) + + else -> children.forEach { block(it, depth + 1) } + } + } + + private fun headingLevel(node: JsonObject): Int = + ImportText.string(node["tag"])?.removePrefix("h")?.toIntOrNull() ?: DEFAULT_HEADING + + /** Nested lists are flattened in reading order. */ + private fun list(node: JsonObject, depth: Int) { + val type = when (ImportText.string(node["listType"])) { + "number" -> BlockType.LIST_NUMBER + "check" -> BlockType.LIST_CHECK + else -> BlockType.LIST_BULLET + } + val items = ArrayList() + collect(node, items, depth) + if (items.isNotEmpty()) blocks += ImportBlocks.list(type, items) + } + + private fun collect(list: JsonObject, into: MutableList, depth: Int) { + if (depth > MAX_DEPTH) return + for (item in children(list)) { + val value = inline(item, skipLists = true).trim() + if (value.isNotEmpty()) into += ItemListBlock(value, isCheck = ImportText.bool(item["checked"]) == true) + children(item).filter { ImportText.string(it["type"]) == "list" }.forEach { collect(it, into, depth + 1) } + } + } + + private fun inline(node: JsonObject, skipLists: Boolean = false): String = buildString { + fun walk(current: JsonObject, depth: Int) { + val skipped = depth > MAX_DEPTH + when (ImportText.string(current["type"])) { + "text", "code-highlight" -> append(ImportText.string(current["text"]).orEmpty()) + "linebreak" -> append('\n') + "tab" -> append('\t') + "list" -> if (!skipLists && !skipped) children(current).forEach { walk(it, depth + 1) } + else -> if (!skipped) children(current).forEach { walk(it, depth + 1) } + } + } + children(node).forEach { walk(it, 0) } + } + + private fun children(node: JsonObject): List = + (node["children"] as? JsonArray).orEmpty().mapNotNull(ImportText::obj) + + companion object { + private const val MAX_DEPTH = 40 + private const val DEFAULT_HEADING = 2 + private val INLINE_TYPES = setOf("text", "code-highlight", "linebreak", "tab", "link", "autolink") + + /** [text] as blocks, or null if it is not a Lexical document. */ + fun parse(text: String): List? { + val root = ImportText.obj(ImportText.json(text))?.get("root")?.let(ImportText::obj) + return root?.takeIf { ImportText.string(it["type"]) == "root" }?.let { document -> + LexicalBlocks().also { lexical -> lexical.children(document).forEach { lexical.block(it, 0) } }.blocks + } + } + } +} diff --git a/app/src/main/java/com/pasich/encly/data/importer/MarkdownBlockMapper.kt b/app/src/main/java/com/pasich/encly/data/importer/MarkdownBlockMapper.kt new file mode 100644 index 00000000..040edffa --- /dev/null +++ b/app/src/main/java/com/pasich/encly/data/importer/MarkdownBlockMapper.kt @@ -0,0 +1,232 @@ +package com.pasich.encly.data.importer + +import com.pasich.encly.data.handoff.InlineHtml +import com.pasich.encly.domain.model.ItemListBlock +import com.pasich.encly.dynamicBlocks.Block +import com.pasich.encly.dynamicBlocks.BlockType + +/** + * Markdown as Encly blocks. Line based and forgiving; only the markup it reads is removed. + * + * - `#`..`####` -> H1-H4 (5 and 6 -> H4); `- [ ]` / `- [x]` -> LIST_CHECK; `-` `*` `+` -> + * LIST_BULLET; `1.` -> LIST_NUMBER (nested items are flattened, Encly lists are flat); + * `>` -> QUOTE; `---` / `***` -> SEPARATOR; everything else -> TEXT, one block per paragraph. + * - `✅` / `☐` lines (Notesnook text exports) are checklist items. An Obsidian callout keeps its title + * (`> [!note] Decisions` -> `Decisions`), a wiki link keeps its alias or name. + * - Fenced code keeps its lines as TEXT, markers removed. Inline markdown (`**bold**`, links) is + * kept as typed, which reads fine as plain text; HTML tags are removed and entities decoded + * ([InlineHtml]) outside code spans: the inline tags of real HTML (``, ``, `
`, ...) + * go and their text stays, while other angle-bracket text (`Map`, ``) is kept. + */ +object MarkdownBlockMapper { + private val HEADING = Regex("""^ {0,3}(#{1,6})[ \t]+(.*?)(?:[ \t]+#+)?[ \t]*$""") + private val CHECK = Regex("""^[ \t]*[-*+][ \t]+\[([ xX])\][ \t]+(.*)$""") + private val GLYPH_ITEM = Regex("""^[ \t]*([✅☑☐])[ \t]*(.*)$""") + private val CALLOUT = Regex("""^\[![A-Za-z-]+][+-]?[ \t]*""") + + // No `[` in a name or alias: otherwise every `[[` of a long run of brackets scans to the end of the line. + private val WIKI_LINK = Regex("""!?\[\[([^\[\]|\n]+)(?:\|([^\[\]\n]+))?]]""") + private val BULLET = Regex("""^[ \t]*[-*+][ \t]+(.*)$""") + + // Up to three digits: "2024. Report" is text. Encly numbers its items itself, so a start number is not kept. + private val NUMBER = Regex("""^[ \t]*\d{1,3}[.)][ \t]+(.*)$""") + private val QUOTE = Regex("""^ {0,3}>[ \t]?(.*)$""") + private val SEPARATOR = Regex("""^ {0,3}([-*_])(?:[ \t]*\1){2,}[ \t]*$""") + private val FENCE = Regex("""^ {0,3}(```|~~~).*$""") + private val CODE_SPAN = Regex("""`[^`\n]+`""") + private val AUTOLINK = Regex("""<((?:https?://|mailto:)[^\s<>]+|[^\s<>@/]+@[^\s<>@/]+)>""") + + fun toBlocks(markdown: String): List = Builder().apply { markdown.lines().forEach(::line) }.finish() + + /** + * The same line rules for text the user pastes into the editor, where nothing may be + * rewritten: HTML, wiki links and callout markers stay as typed, fence lines stay in their + * code paragraph, and blank list items are kept. [claim] may take a line whole as a block of + * its own (a lone web address); it runs before every other rule. With [plainFirstLine] the + * first line is plain text whatever it starts with, for a paste that continues a line. + * [toBlocks] is unchanged. + */ + internal fun pasteBlocks( + text: String, + plainFirstLine: Boolean = false, + claim: (String) -> Block? = { null }, + ): List = Builder(PasteRules(claim, plainFirstLine)).apply { text.lines().forEach(::line) }.finish() + + private class PasteRules(val claim: (String) -> Block?, var plainFirstLine: Boolean) + + /** [text] with HTML removed outside code spans. */ + internal fun inline(text: String): String { + val linked = AUTOLINK.replace(text, "$1") + val out = StringBuilder() + var last = 0 + for (span in CODE_SPAN.findAll(linked)) { + out.append(plain(linked.substring(last, span.range.first))).append(span.value) + last = span.range.last + 1 + } + return out.append(plain(linked.substring(last))).toString() + } + + /** Wiki links as the text a reader sees (`[[Note|alias]]` -> alias), then no HTML. */ + private fun plain(text: String): String = InlineHtml.toPlainText( + WIKI_LINK.replace(text) { it.groupValues[2].ifEmpty { it.groupValues[1] } }, + knownTagsOnly = true, + ) + + private class Builder(private val paste: PasteRules? = null) { + private val blocks = ArrayList() + private val paragraph = ArrayList() + private val quote = ArrayList() + private val code = ArrayList() + private val items = ArrayList() + private var listType: BlockType? = null + private var inFence = false + + fun line(raw: String) { + if (paste?.plainFirstLine == true) { + paste.plainFirstLine = false + if (raw.isNotBlank()) { + paragraph += raw.trimEnd() + return + } + } + val fence = FENCE.matches(raw) + when { + inFence && fence -> { + if (paste != null) code += raw + closeFence() + } + + inFence -> code += raw + + fence -> { + flushAll() + inFence = true + if (paste != null) code += raw + } + + raw.isBlank() -> { + // A blank line ends a paragraph or quote but not a list: loose lists stay one block. + flushParagraph() + flushQuote() + } + + else -> block(raw) + } + } + + fun finish(): List { + if (inFence) closeFence() + flushAll() + return blocks + } + + private fun block(raw: String) { + val heading = HEADING.matchEntire(raw) + val check = CHECK.matchEntire(raw) + val glyph = GLYPH_ITEM.matchEntire(raw) + val bullet = BULLET.matchEntire(raw) + val number = NUMBER.matchEntire(raw) + val quoted = QUOTE.matchEntire(raw) + val claimed = paste?.claim?.invoke(raw) + when { + claimed != null -> { + flushAll() + blocks += claimed + } + + SEPARATOR.matches(raw) -> { + flushAll() + blocks += Block.SeparatorBlock() + } + + heading != null -> { + flushAll() + val level = heading.groupValues[1].length + blocks += ImportBlocks.heading(readable(heading.groupValues[2]), level) + } + + check != null -> item(BlockType.LIST_CHECK, check.groupValues[2], check.groupValues[1] != " ") + + glyph != null -> item(BlockType.LIST_CHECK, glyph.groupValues[2], glyph.groupValues[1] != "☐") + + bullet != null -> item(BlockType.LIST_BULLET, bullet.groupValues[1], false) + + number != null -> item(BlockType.LIST_NUMBER, number.groupValues[1], false) + + quoted != null -> { + flushParagraph() + flushList() + quote += + readable( + if (paste == + null + ) { + CALLOUT.replaceFirst(quoted.groupValues[1], "") + } else { + quoted.groupValues[1] + }, + ) + } + + // Indented text right after a list item continues that item. + listType != null && raw.first().isWhitespace() -> { + val last = items.removeAt(items.lastIndex) + items += last.copy(value = (last.value + " " + readable(raw.trim())).trim()) + } + + else -> { + flushList() + flushQuote() + paragraph += readable(raw.trimEnd()) + } + } + } + + /** [text] as the importer reads it; a paste keeps it as it is. */ + private fun readable(text: String): String = if (paste == null) inline(text) else text + + private fun item(type: BlockType, text: String, checked: Boolean) { + flushParagraph() + flushQuote() + if (listType != type) flushList() + listType = type + items += ItemListBlock(value = readable(text).trim(), isCheck = checked) + } + + private fun closeFence() { + inFence = false + val text = code.joinToString("\n").trimEnd() + code.clear() + if (text.isNotBlank()) blocks += ImportBlocks.text(text) + } + + private fun flushAll() { + flushParagraph() + flushQuote() + flushList() + } + + private fun flushParagraph() { + if (paragraph.isEmpty()) return + val text = paragraph.joinToString("\n").trim() + paragraph.clear() + if (text.isNotEmpty()) blocks += ImportBlocks.text(text) + } + + private fun flushQuote() { + if (quote.isEmpty()) return + val text = quote.joinToString("\n").trim() + quote.clear() + if (text.isNotEmpty()) blocks += ImportBlocks.quote(text) + } + + private fun flushList() { + val type = listType ?: return + // A paste keeps an empty item (an empty checklist template); an import drops it. + val kept = if (paste != null) items.toList() else items.filter { it.value.isNotBlank() } + items.clear() + listType = null + if (kept.isNotEmpty()) blocks += ImportBlocks.list(type, kept) + } + } +} diff --git a/app/src/main/java/com/pasich/encly/data/importer/MarkdownNotesParser.kt b/app/src/main/java/com/pasich/encly/data/importer/MarkdownNotesParser.kt new file mode 100644 index 00000000..fb02fa68 --- /dev/null +++ b/app/src/main/java/com/pasich/encly/data/importer/MarkdownNotesParser.kt @@ -0,0 +1,115 @@ +package com.pasich.encly.data.importer + +/** + * Markdown / TXT files: generic (Obsidian, Markor, Joplin's Markdown export, ...) and the + * Notesnook export, which differs only in its extras ([notesnook]). + * + * - Title: front matter `title`, else the file name. A first line that repeats the file name + * (`# Meeting notes` in `Meeting-notes.md`, as Notesnook and some other exporters write it) is + * the real title and is not repeated in the text; any other heading stays in the text. + * - Front matter (a safe subset, see [Frontmatter]): `tags` (list or comma separated), + * `created` / `created_at` / `date`, `updated` / `updated_at` / `modified`. Nothing else is + * read and nothing is executed. Inline `#tags` in the text stay text. + * - Tag: the front matter tags, else the first folder of the path (`_archive` is no tag). + * - Notesnook: `pinned`, `archived`, `color` are counted as losses, `_archive/` marks archived + * notes, `attachments/` files stay behind, and a `.txt` export starts with the title line. + * These rules apply to the files that are Notesnook's ([parse]'s `notesnookFile`), not to every + * file of a mixed selection. + * - Identity: a note has no id of its own, so its key is its path plus a digest of its title and + * text. Two `todo.md` of different folders stay two notes, an edited re-export is a new note, + * and the same file imported again is not imported twice. + * - Image and media files next to the notes are not imported and are counted as attachments. + */ +internal object MarkdownNotesParser { + private val TEXT_EXTENSIONS = setOf("md", "markdown", "mdown", "txt", "text") + private val PLAIN_EXTENSIONS = setOf("txt", "text") + private val SEPARATORS = Regex("""[\s_-]+""") + private const val ARCHIVE_DIR = "_archive" + private const val ATTACHMENTS_DIR = "attachments" + + fun isTextFile(entry: ImportEntry): Boolean = entry.extension in TEXT_EXTENSIONS + + /** + * [notesnook] is the selection's source; [notesnookFile] says which of its text files carry + * Notesnook's extras (all of them, by default). + */ + fun parse( + entries: List, + limits: ImportLimits, + notesnook: Boolean, + notesnookFile: (ImportEntry) -> Boolean = { notesnook }, + ): ParsedNotes { + val context = ParseContext(limits) + var attachments = 0 + for (entry in entries) { + val dirs = entry.path.split('/').dropLast(1) + val nn = notesnook && notesnookFile(entry) + when { + (ATTACHMENTS_DIR in dirs && notesnook && (nn || !isTextFile(entry))) || ImportKinds.isMedia(entry) -> + attachments++ + + isTextFile(entry) -> context.text(entry)?.let { context.add(note(entry, it, dirs, nn)) } + + ImportKinds.isIgnorable(entry) -> Unit + + else -> context.unsupported++ + } + } + context.extraLosses = ImportLosses(attachments = attachments) + return context.result() + } + + private class Titled(val title: String, val body: String) + + private fun note(entry: ImportEntry, text: String, dirs: List, notesnook: Boolean): ImportedNote { + val plain = entry.extension in PLAIN_EXTENSIONS + val doc = if (plain) MarkdownDocument(emptyMap(), text) else Frontmatter.split(text) + val titled = titled(doc, entry.name.substringBeforeLast('.'), plain, firstLineIsTitle = notesnook && plain) + val folder = dirs.firstOrNull { it != ARCHIVE_DIR } + val tags = ImportText.names(doc.list("tags")).ifEmpty { ImportText.names(listOfNotNull(folder)) } + val blocks = MarkdownBlockMapper.toBlocks(titled.body) + return ImportedNote( + sourceKey = entry.path + "|" + ImportMapper.contentDigest(titled.title, blocks), + title = titled.title, + blocks = blocks, + tags = tags, + created = ImportTime.parse(doc.firstValue("created_at", "created", "date")), + edited = ImportTime.parse(doc.firstValue("updated_at", "updated", "modified")), + losses = losses(doc, tags.size, notesnook, archivedFolder = ARCHIVE_DIR in dirs), + ) + } + + /** The title and what is left of the text once a first line that only repeats the title is dropped. */ + private fun titled(doc: MarkdownDocument, fileTitle: String, plain: Boolean, firstLineIsTitle: Boolean): Titled { + val frontTitle = doc.value("title")?.trim() + val lines = doc.body.lines() + val index = lines.indexOfFirst { it.isNotBlank() } + val first = lines.getOrNull(index)?.trim() + val candidate = when { + first == null -> null + plain -> first + first.startsWith("# ") -> first.removePrefix("# ").trim() + else -> null + } + val repeats = candidate != null && when { + firstLineIsTitle -> true + frontTitle != null -> candidate == frontTitle + else -> sameName(candidate, fileTitle) + } + val title = frontTitle ?: if (repeats) candidate else fileTitle + return Titled(title.orEmpty().trim(), if (repeats) lines.drop(index + 1).joinToString("\n") else doc.body) + } + + /** File names turn spaces into `-` or `_`: "Meeting-notes" is the name of "Meeting notes". */ + private fun sameName(title: String, fileTitle: String): Boolean = + title.lowercase().replace(SEPARATORS, " ").trim() == fileTitle.lowercase().replace(SEPARATORS, " ").trim() + + /** Notesnook's extras that Encly has no counterpart for, and labels beyond the first. */ + private fun losses(doc: MarkdownDocument, tagCount: Int, notesnook: Boolean, archivedFolder: Boolean) = + ImportLosses( + pinned = if (notesnook && doc.flag("pinned")) 1 else 0, + archived = if (archivedFolder || (notesnook && doc.flag("archived"))) 1 else 0, + colors = if (notesnook && doc.value("color") != null) 1 else 0, + extraTags = (tagCount - 1).coerceAtLeast(0), + ) +} diff --git a/app/src/main/java/com/pasich/encly/data/importer/SafImportFiles.kt b/app/src/main/java/com/pasich/encly/data/importer/SafImportFiles.kt new file mode 100644 index 00000000..dd040046 --- /dev/null +++ b/app/src/main/java/com/pasich/encly/data/importer/SafImportFiles.kt @@ -0,0 +1,72 @@ +package com.pasich.encly.data.importer + +import android.content.ContentResolver +import android.content.Context +import android.net.Uri +import android.provider.DocumentsContract +import android.provider.OpenableColumns +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject + +/** + * The Storage Access Framework side of an import: the user's picked files or folder as an + * [ImportFileSource]. Only [ContentResolver.openInputStream] is used (no file paths, seeking or + * reliance on a provider's size), and a folder is walked with [DocumentsContract] up to the + * depth and entry limits. The user's files are only ever read. + */ +class SafImportFiles @Inject constructor(@param:ApplicationContext private val context: Context) { + + fun files(uris: List): ImportFileSource = ImportFileSource { + uris.asSequence().map { uri -> SourceFile(displayName(uri) ?: FALLBACK_NAME) { open(uri) } } + } + + fun tree(tree: Uri, limits: ImportLimits = ImportLimits()): ImportFileSource = + TreeWalker(SafTree(tree, limits), limits).source() + + private fun open(uri: Uri) = context.contentResolver.openInputStream(uri) + + private fun displayName(uri: Uri): String? = try { + context.contentResolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)?.use { + if (it.moveToFirst()) it.getString(0) else null + } + } catch (_: SecurityException) { + null + } catch (_: IllegalArgumentException) { + null + } + + /** A picked folder through [DocumentsContract]; document ids are the provider's. */ + private inner class SafTree(private val tree: Uri, private val limits: ImportLimits) : DocumentTree { + override val rootId: String = DocumentsContract.getTreeDocumentId(tree) + + /** Collected at once: a provider cursor must not stay open across a lazily consumed sequence. */ + override fun children(parentId: String): List { + val uri = DocumentsContract.buildChildDocumentsUriUsingTree(tree, parentId) + val columns = arrayOf( + DocumentsContract.Document.COLUMN_DOCUMENT_ID, + DocumentsContract.Document.COLUMN_DISPLAY_NAME, + DocumentsContract.Document.COLUMN_MIME_TYPE, + ) + val found = ArrayList() + try { + context.contentResolver.query(uri, columns, null, null, null)?.use { cursor -> + while (cursor.moveToNext() && found.size < limits.maxEntries) { + val directory = cursor.getString(2) == DocumentsContract.Document.MIME_TYPE_DIR + found += TreeNode(cursor.getString(0), cursor.getString(1).orEmpty(), directory) + } + } + } catch (_: SecurityException) { + // A folder the provider will not list is skipped; the rest of the walk goes on. + } catch (_: IllegalArgumentException) { + // Same for a malformed document id. + } + return found + } + + override fun open(id: String) = open(DocumentsContract.buildDocumentUriUsingTree(tree, id)) + } + + private companion object { + const val FALLBACK_NAME = "file" + } +} diff --git a/app/src/main/java/com/pasich/encly/data/importer/StandardNotesParser.kt b/app/src/main/java/com/pasich/encly/data/importer/StandardNotesParser.kt new file mode 100644 index 00000000..df411ac1 --- /dev/null +++ b/app/src/main/java/com/pasich/encly/data/importer/StandardNotesParser.kt @@ -0,0 +1,132 @@ +package com.pasich.encly.data.importer + +import com.pasich.encly.data.handoff.InlineHtml +import com.pasich.encly.dynamicBlocks.Block +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject + +/** + * Standard Notes, decrypted backup: `{version, items: [...]}` (the ZIP of a decrypted download + * holds this file plus a plain `Items/` folder, which is ignored). + * + * Only `Note` and `Tag` items are read; `SN|*` items are the app's own settings. A tag's + * `references` give the notes their tag. The text depends on `noteType`: plain text and markdown + * through [MarkdownBlockMapper] (so `- [x]` lines become checklists), Super (Lexical JSON) by its + * text nodes ([LexicalBlocks]), rich text without its HTML, every other type as plain paragraphs. + * Pinned, archived and the client's edit time live in `appData["org.standardnotes.sn"]`. An + * encrypted backup is never read ([Shape.ENCRYPTED]). + */ +internal object StandardNotesParser { + const val BACKUP_FILE_PREFIX = "Standard Notes Backup and Import File" + private const val NOTE = "Note" + private const val TAG = "Tag" + private const val APP_DATA = "org.standardnotes.sn" + + enum class Shape { DECRYPTED, ENCRYPTED } + + /** What [json] is if it has the shape of a Standard Notes backup, else null. */ + fun shape(json: JsonElement?): Shape? { + val root = ImportText.obj(json) + val items = (root?.get("items") as? JsonArray)?.mapNotNull(ImportText::obj).orEmpty() + return when { + root == null || root["items"] !is JsonArray || root["version"] == null -> null + + root["keyParams"] != null || root["auth_params"] != null || items.any(::hasEncryptedContent) -> + Shape.ENCRYPTED + + items.any { ImportText.string(it["content_type"]) != null } -> Shape.DECRYPTED + + else -> null + } + } + + private fun hasEncryptedContent(item: JsonObject): Boolean { + val type = ImportText.string(item["content_type"]) + return (type == NOTE || type == TAG) && ImportText.string(item["content"]) != null + } + + fun parse(entries: List, limits: ImportLimits): ParsedNotes { + val context = ParseContext(limits) + for (entry in entries.filter { it.name.startsWith(BACKUP_FILE_PREFIX) || it.extension == "json" }) { + val text = context.text(entry) + val json = text?.let { ImportText.json(it, limits.maxJsonDepth) } + val root = ImportText.obj(json) + when { + text == null -> Unit + root != null && shape(json) == Shape.DECRYPTED -> readBackup(root, context) + else -> context.unsupported++ + } + } + return context.result() + } + + private fun readBackup(root: JsonObject, context: ParseContext) { + val items = (root["items"] as? JsonArray).orEmpty().mapNotNull(ImportText::obj) + val tagsByNote = tagsByNote(items) + for (item in items.filter { ImportText.string(it["content_type"]) == NOTE }) { + val content = ImportText.obj(item["content"]) + val uuid = ImportText.string(item["uuid"]) + if (content != null) { + // Alphabetical, so the tag Encly keeps does not depend on the order of the file. + val tags = ImportText.names(tagsByNote[uuid].orEmpty().sortedBy { it.lowercase() }) + context.add(note(uuid, item, content, tags)) + } + } + } + + /** Note uuid -> titles of the tags that reference it. */ + private fun tagsByNote(items: List): Map> { + val result = HashMap>() + for (item in items.filter { ImportText.string(it["content_type"]) == TAG }) { + val content = ImportText.obj(item["content"]) + val title = ImportText.string(content?.get("title"))?.trim().orEmpty() + val references = (content?.get("references") as? JsonArray).orEmpty().mapNotNull(ImportText::obj) + for (ref in references.filter { ImportText.string(it["content_type"]) == NOTE && title.isNotEmpty() }) { + result.getOrPut(ImportText.string(ref["uuid"])) { ArrayList() } += title + } + } + return result + } + + /** `content.appData["org.standardnotes.sn"]`, where the app keeps pinned, archived and the edit time. */ + private fun appData(content: JsonObject): JsonObject = + ImportText.obj(ImportText.obj(content["appData"])?.get(APP_DATA)) ?: JsonObject(emptyMap()) + + private fun flag(app: JsonObject, content: JsonObject, key: String): Boolean = + (ImportText.bool(app[key]) ?: ImportText.bool(content[key])) == true + + private fun note(uuid: String?, item: JsonObject, content: JsonObject, tags: List): ImportedNote { + val app = appData(content) + return ImportedNote( + sourceKey = uuid, + title = ImportText.string(content["title"]).orEmpty().trim(), + blocks = blocks(content), + tags = tags, + created = ImportTime.parse(ImportText.string(item["created_at"])), + // An offline export has updated_at = 1970 (rejected by parse()); the edit time is the client's. + edited = ImportTime.parse(ImportText.string(app["client_updated_at"])) + ?: ImportTime.parse(ImportText.string(item["updated_at"])), + trashed = ImportText.bool(content["trashed"]) == true, + losses = ImportLosses( + pinned = if (flag(app, content, "pinned")) 1 else 0, + archived = if (flag(app, content, "archived")) 1 else 0, + extraTags = (tags.size - 1).coerceAtLeast(0), + ), + ) + } + + private fun blocks(content: JsonObject): List { + val text = ImportText.string(content["text"]).orEmpty().replace("\r\n", "\n") + return when (ImportText.string(content["noteType"])) { + "markdown", "plain-text" -> MarkdownBlockMapper.toBlocks(text) + "rich-text" -> ImportBlocks.paragraphs(InlineHtml.toPlainText(text)) + "super" -> LexicalBlocks.parse(text) ?: fallback(content, text) + else -> ImportBlocks.paragraphs(text) + } + } + + private fun fallback(content: JsonObject, text: String): List = + ImportText.string(content["preview_plain"])?.takeIf { it.isNotBlank() }?.let(ImportBlocks::paragraphs) + ?: ImportBlocks.paragraphs(text) +} diff --git a/app/src/main/java/com/pasich/encly/data/importer/TarReader.kt b/app/src/main/java/com/pasich/encly/data/importer/TarReader.kt new file mode 100644 index 00000000..03ee8807 --- /dev/null +++ b/app/src/main/java/com/pasich/encly/data/importer/TarReader.kt @@ -0,0 +1,207 @@ +package com.pasich.encly.data.importer + +import java.io.IOException +import java.io.InputStream + +/** + * A minimal sequential reader for POSIX ustar / GNU tar (Joplin's JEX export). It never seeks and + * never allocates by a size a header claims: data is handed out as a stream bounded by that + * size, and whatever the caller leaves unread is read and discarded. Headers are checksummed; + * anything malformed is an [IOException]. + * + * Supported: regular files, directories, GNU long names (`L`) and pax `path` records (`x`), + * both bounded. Other types are reported as they are so the caller can refuse them. + */ +// One small format (header fields, padding, long names); a malformed header exits early. +@Suppress("TooManyFunctions", "ReturnCount") +internal class TarReader(private val input: InputStream) { + class Header(val path: String, val type: Char) + + private var remaining = 0L + private var padding = 0 + private var finished = false + + /** The next header, or null at the end of the archive. Leftover data of the last entry is discarded. */ + fun next(): Header? { + if (finished) return null + discard(remaining + padding) + remaining = 0 + padding = 0 + var longName: String? = null + while (true) { + val block = readBlock() ?: return end() + if (block.all { it == ZERO }) return end() + if (!checksumOk(block)) throw IOException("bad tar checksum") + val type = block[TYPE_OFFSET].toInt().toChar() + val size = octal(block, SIZE_OFFSET, SIZE_LENGTH) + when (type) { + GNU_LONG_NAME, PAX_HEADER -> { + val data = readSmall(size) + longName = if (type == PAX_HEADER) paxPath(data) ?: longName else cString(data, 0, data.size) + } + + PAX_GLOBAL -> discard(size + paddingFor(size)) + + else -> { + remaining = size + padding = paddingFor(size) + return Header(longName ?: name(block), type) + } + } + } + } + + /** The data of the current entry; reading past its size is end of stream. */ + fun data(): InputStream = object : InputStream() { + override fun read(): Int { + val one = ByteArray(1) + return if (read(one, 0, 1) < 0) -1 else one[0].toInt() and BYTE_MASK + } + + override fun read(b: ByteArray, off: Int, len: Int): Int { + if (remaining <= 0) return -1 + val n = input.read(b, off, minOf(len.toLong(), remaining).toInt()) + if (n < 0) throw IOException("truncated tar") + remaining -= n + return n + } + } + + private fun end(): Header? { + finished = true + return null + } + + private fun readBlock(): ByteArray? { + val block = ByteArray(BLOCK) + var read = 0 + while (read < BLOCK) { + val n = input.read(block, read, BLOCK - read) + if (n < 0) { + if (read == 0) return null + throw IOException("truncated tar") + } + read += n + } + return block + } + + private fun readSmall(size: Long): ByteArray { + if (size < 0 || size > MAX_META) throw IOException("tar header too long") + val data = ByteArray(size.toInt()) + var read = 0 + while (read < data.size) { + val n = input.read(data, read, data.size - read) + if (n < 0) throw IOException("truncated tar") + read += n + } + discard(paddingFor(size).toLong()) + return data + } + + private fun discard(count: Long) { + var left = count + val sink = ByteArray(BLOCK) + while (left > 0) { + val n = input.read(sink, 0, minOf(left, sink.size.toLong()).toInt()) + if (n < 0) throw IOException("truncated tar") + left -= n + } + } + + private fun paddingFor(size: Long): Int = ((BLOCK - size % BLOCK) % BLOCK).toInt() + + private fun name(block: ByteArray): String { + val base = cString(block, 0, NAME_LENGTH) + val ustar = String(block, MAGIC_OFFSET, USTAR.length, Charsets.ISO_8859_1) == USTAR + val prefix = if (ustar) cString(block, PREFIX_OFFSET, PREFIX_LENGTH) else "" + return if (prefix.isEmpty()) base else "$prefix/$base" + } + + private fun cString(bytes: ByteArray, offset: Int, length: Int): String { + var end = offset + while (end < offset + length && bytes[end] != ZERO) end++ + return String(bytes, offset, end - offset, Charsets.UTF_8) + } + + private fun octal(block: ByteArray, offset: Int, length: Int): Long { + // GNU base-256 sizes (high bit set) mean an entry far beyond any limit. + if (block[offset].toInt() and HIGH_BIT != 0) throw IOException("tar entry too large") + var value = 0L + var i = offset + while (i < offset + length && block[i].toInt().toChar() == ' ') i++ + while (i < offset + length) { + val c = block[i].toInt().toChar() + if (c == ' ' || c == '\u0000') break + if (c !in '0'..'7') throw IOException("bad tar number") + value = value * OCTAL + (c - '0') + i++ + } + return value + } + + internal fun checksumOk(block: ByteArray): Boolean { + var sum = 0L + for (i in block.indices) { + sum += if (i in CHECKSUM_OFFSET until CHECKSUM_OFFSET + CHECKSUM_LENGTH) { + ' '.code + } else { + block[i].toInt() and BYTE_MASK + } + } + return sum == octal(block, CHECKSUM_OFFSET, CHECKSUM_LENGTH) + } + + /** The `path` of a pax extended header: records of ` =\n`. */ + private fun paxPath(data: ByteArray): String? { + var pos = 0 + while (pos < data.size) { + val space = data.indexOf(' '.code.toByte(), pos) + if (space < 0) return null + val length = String(data, pos, space - pos, Charsets.US_ASCII).toIntOrNull() ?: return null + if (length <= space - pos + 1 || pos + length > data.size) return null + val record = String(data, space + 1, pos + length - space - 2, Charsets.UTF_8) + if (record.startsWith("path=")) return record.removePrefix("path=") + pos += length + } + return null + } + + private fun ByteArray.indexOf(value: Byte, from: Int): Int { + for (i in from until size) if (this[i] == value) return i + return -1 + } + + companion object { + const val BLOCK = 512 + const val USTAR = "ustar" + const val GNU_LONG_NAME = 'L' + const val PAX_HEADER = 'x' + const val PAX_GLOBAL = 'g' + const val REGULAR = '0' + const val REGULAR_OLD = '\u0000' + const val DIRECTORY = '5' + + private const val ZERO: Byte = 0 + private const val NAME_LENGTH = 100 + private const val SIZE_OFFSET = 124 + private const val SIZE_LENGTH = 12 + private const val CHECKSUM_OFFSET = 148 + private const val CHECKSUM_LENGTH = 8 + private const val TYPE_OFFSET = 156 + private const val MAGIC_OFFSET = 257 + private const val PREFIX_OFFSET = 345 + private const val PREFIX_LENGTH = 155 + private const val MAX_META = 64L * 1024 + private const val OCTAL = 8 + private const val HIGH_BIT = 0x80 + private const val BYTE_MASK = 0xff + + /** Whether [head] (the first bytes of a file) is a valid tar header: a magic plus a checksum. */ + fun looksLikeTar(head: ByteArray): Boolean { + if (head.size < BLOCK) return false + if (String(head, MAGIC_OFFSET, USTAR.length, Charsets.ISO_8859_1) != USTAR) return false + return runCatching { TarReader(head.inputStream()).checksumOk(head.copyOf(BLOCK)) }.getOrDefault(false) + } + } +} diff --git a/app/src/main/java/com/pasich/encly/data/importer/ZipStreamReader.kt b/app/src/main/java/com/pasich/encly/data/importer/ZipStreamReader.kt new file mode 100644 index 00000000..c46adc58 --- /dev/null +++ b/app/src/main/java/com/pasich/encly/data/importer/ZipStreamReader.kt @@ -0,0 +1,309 @@ +package com.pasich.encly.data.importer + +import java.io.IOException +import java.io.InputStream +import java.nio.ByteBuffer +import java.nio.charset.CharacterCodingException +import java.nio.charset.Charset +import java.nio.charset.CodingErrorAction +import java.util.zip.CRC32 +import java.util.zip.DataFormatException +import java.util.zip.Inflater + +/** + * A sequential ZIP reader, because [java.util.zip.ZipInputStream] cannot read what streaming + * writers produce: ZIP64 archives with a data descriptor (Notesnook's export, among others) fail + * there with "invalid entry size". This reads local headers and entry data only, never the + * central directory, so it needs no seeking and works on any stream. + * + * Supported: stored entries with known sizes and deflated entries, with or without a data + * descriptor (32- or 64-bit sizes, signature or not, told apart by the sizes the inflater actually + * produced). Encrypted entries and other methods are an [IOException]. Each entry's CRC-32 is + * checked. Names are UTF-8 when the archive says so (or when they are valid UTF-8 anyway) and + * CP437 otherwise, so different names stay different; paths are validated by the caller, never here. + */ +// One small format (header, ZIP64 extra, descriptor layouts, buffer); every corrupt archive is an IOException. +@Suppress("TooManyFunctions", "ThrowsCount") +internal class ZipStreamReader(private val input: InputStream) { + class Entry(val name: String) { + val isDirectory: Boolean get() = name.endsWith("/") + } + + private class State(val flags: Int, val method: Int, val expectedCrc: Long, var compressed: Long, var size: Long) { + val crc = CRC32() + val inflater = if (method == DEFLATED) Inflater(true) else null + var fed = 0 + var storedLeft = size + var done = false + } + + private val buf = ByteArray(BUFFER) + private var pos = 0 + private var lim = 0 + private var state: State? = null + + /** + * The next entry, or null at the central directory, an end record or a clean end of the + * stream; unread data of the last one is dropped. Any other bytes between entries are an + * [IOException], so a damaged archive never looks like a finished one. + */ + fun next(): Entry? { + state?.let { drain(it) } + state = null + val signature = readIntOrNull() + return when (signature) { + null -> null + LOCAL_HEADER -> open(readExact(LOCAL_FIXED)) + in END_SIGNATURES -> null + else -> throw IOException("bad zip signature") + } + } + + /** The uncompressed data of the current entry. */ + fun data(): InputStream = object : InputStream() { + override fun read(): Int { + val one = ByteArray(1) + return if (read(one, 0, 1) < 0) -1 else one[0].toInt() and BYTE_MASK + } + + override fun read(b: ByteArray, off: Int, len: Int): Int = state?.let { readData(it, b, off, len) } ?: -1 + } + + private fun open(header: ByteArray): Entry { + val flags = u16(header, FLAGS) + val method = u16(header, METHOD) + if (flags and ENCRYPTED != 0 || + (method != STORED && method != DEFLATED) + ) { + throw IOException("unsupported zip entry") + } + val name = decodeName(readExact(u16(header, NAME_LENGTH))) + val extra = readExact(u16(header, EXTRA_LENGTH)) + var compressed = u32(header, COMPRESSED) + var size = u32(header, SIZE) + val zip64 = zip64Sizes(extra) + if (zip64 != null && size == MAX_32) size = zip64.first + if (zip64 != null && compressed == MAX_32) compressed = zip64.second + if (method == STORED && flags and DESCRIPTOR != 0) throw IOException("stored zip entry of unknown size") + state = State(flags, method, u32(header, CRC), compressed, size) + return Entry(name) + } + + /** + * A name as UTF-8 if the bytes are valid UTF-8 (what the UTF-8 flag, bit 11, promises, and what + * many writers produce without it), else as CP437, the format's default: a name that breaks + * the promise still decodes to distinct text instead of U+FFFD. + */ + private fun decodeName(bytes: ByteArray): String { + val utf8 = try { + Charsets.UTF_8.newDecoder() + .onMalformedInput(CodingErrorAction.REPORT) + .onUnmappableCharacter(CodingErrorAction.REPORT) + .decode(ByteBuffer.wrap(bytes)).toString() + } catch (_: CharacterCodingException) { + null + } + return utf8 ?: String(bytes, cp437) + } + + /** The (size, compressed size) of the ZIP64 extra field, if present. */ + private fun zip64Sizes(extra: ByteArray): Pair? { + var at = 0 + var found: Pair? = null + while (at + EXTRA_HEADER <= extra.size && found == null) { + val id = u16(extra, at) + val length = u16(extra, at + 2) + if (id == ZIP64_ID && length >= ZIP64_SIZES && at + EXTRA_HEADER + ZIP64_SIZES <= extra.size) { + found = u64(extra, at + EXTRA_HEADER) to u64(extra, at + EXTRA_HEADER + LONG) + } + at += EXTRA_HEADER + length + } + return found + } + + private fun readData(entry: State, b: ByteArray, off: Int, len: Int): Int { + var result = -1 + if (!entry.done && len > 0) { + result = + if (entry.inflater == + null + ) { + readStored(entry, b, off, len) + } else { + inflate(entry, entry.inflater, b, off, len) + } + if (result > 0) entry.crc.update(b, off, result) + } + return result + } + + private fun readStored(entry: State, b: ByteArray, off: Int, len: Int): Int { + if (entry.storedLeft <= 0) return finish(entry) + if (!fill()) throw IOException("truncated zip") + val n = minOf(len.toLong(), entry.storedLeft, (lim - pos).toLong()).toInt() + System.arraycopy(buf, pos, b, off, n) + pos += n + entry.storedLeft -= n + return n + } + + private fun inflate(entry: State, inflater: Inflater, b: ByteArray, off: Int, len: Int): Int { + while (true) { + if (inflater.needsInput()) { + sync(entry, inflater) + if (!fill()) throw IOException("truncated zip") + inflater.setInput(buf, pos, lim - pos) + entry.fed = lim - pos + } + val n = try { + inflater.inflate(b, off, len) + } catch (_: DataFormatException) { + throw IOException("bad zip data") + } + when { + n > 0 -> return n + + inflater.finished() -> { + sync(entry, inflater) + entry.compressed = inflater.bytesRead + entry.size = inflater.bytesWritten + return finish(entry) + } + + inflater.needsDictionary() -> throw IOException("bad zip data") + } + } + } + + /** Moves the read position past the input the inflater has used. */ + private fun sync(entry: State, inflater: Inflater) { + pos += entry.fed - inflater.remaining + entry.fed = 0 + } + + /** The entry's data is over: read its descriptor if it has one and check the CRC. */ + private fun finish(entry: State): Int { + entry.done = true + entry.inflater?.end() + val crc = if (entry.flags and DESCRIPTOR != 0) descriptor(entry) else entry.expectedCrc + if (crc != entry.crc.value) throw IOException("zip checksum mismatch") + return -1 + } + + /** The CRC from a data descriptor, in whichever of its four layouts matches the sizes just read. */ + private fun descriptor(entry: State): Long { + val bytes = peek(DESCRIPTOR_MAX) + val signed = bytes.size >= INT && le(bytes, 0, INT) == DESCRIPTOR_SIGNATURE + val start = if (signed) INT else 0 + val wide = bytes.size >= start + INT + 2 * LONG && + le(bytes, start + INT, LONG) == entry.compressed && le(bytes, start + INT + LONG, LONG) == entry.size + val narrow = bytes.size >= start + INT + 2 * INT && + le(bytes, start + INT, INT) == entry.compressed && le(bytes, start + 2 * INT, INT) == entry.size + val length = start + INT + if (wide) 2 * LONG else 2 * INT + if (!wide && !narrow) throw IOException("bad zip data descriptor") + val crc = le(bytes, start, INT) + pos += length + return crc + } + + /** Reads and drops what the caller left of an entry, at most [MAX_DRAIN] bytes: a directory with data is a bomb. */ + private fun drain(entry: State) { + val sink = ByteArray(BUFFER) + var dropped = 0L + while (!entry.done) { + dropped += readData(entry, sink, 0, sink.size).coerceAtLeast(0) + if (dropped > MAX_DRAIN) throw IOException("zip entry skipped with too much data") + } + } + + // Buffer ------------------------------------------------------------------------------ + + private fun fill(): Boolean { + if (pos >= lim) { + lim = input.read(buf, 0, buf.size).coerceAtLeast(0) + pos = 0 + } + return pos < lim + } + + /** Up to [count] upcoming bytes, not consumed (fewer at the end of the stream). */ + private fun peek(count: Int): ByteArray { + if (lim - pos < count) { + System.arraycopy(buf, pos, buf, 0, lim - pos) + lim -= pos + pos = 0 + while (lim < count) { + val n = input.read(buf, lim, buf.size - lim) + if (n < 0) break + lim += n + } + } + return buf.copyOfRange(pos, minOf(lim, pos + count)) + } + + private fun readExact(count: Int): ByteArray { + val out = ByteArray(count) + var done = 0 + while (done < count) { + if (!fill()) throw IOException("truncated zip") + val n = minOf(count - done, lim - pos) + System.arraycopy(buf, pos, out, done, n) + pos += n + done += n + } + return out + } + + private fun readIntOrNull(): Long? = if (fill()) le(readExact(INT), 0, INT) else null + + private fun u16(b: ByteArray, at: Int) = le(b, at, SHORT).toInt() + + private fun u32(b: ByteArray, at: Int) = le(b, at, INT) + + private fun u64(b: ByteArray, at: Int) = le(b, at, LONG) + + private fun le(b: ByteArray, at: Int, bytes: Int): Long { + var value = 0L + for (i in bytes - 1 downTo 0) value = (value shl BITS) or (b[at + i].toLong() and BYTE_MASK.toLong()) + return value + } + + private companion object { + val cp437: Charset = try { + Charset.forName("IBM437") + } catch (_: IllegalArgumentException) { + Charsets.ISO_8859_1 + } + const val BUFFER = 16 * 1024 + const val MAX_DRAIN = 1024 * 1024 + const val LOCAL_HEADER = 0x04034b50L + const val DESCRIPTOR_SIGNATURE = 0x08074b50L + + // Central directory header, end of central directory (and its ZIP64 forms), archive extra data. + val END_SIGNATURES = setOf(0x02014b50L, 0x06054b50L, 0x06064b50L, 0x07064b50L, 0x08064b50L) + const val MAX_32 = 0xffffffffL + const val STORED = 0 + const val DEFLATED = 8 + const val ENCRYPTED = 0x1 + const val DESCRIPTOR = 0x8 + const val ZIP64_ID = 1 + const val ZIP64_SIZES = 16 + const val EXTRA_HEADER = 4 + const val DESCRIPTOR_MAX = 24 + const val SHORT = 2 + const val INT = 4 + const val LONG = 8 + const val BITS = 8 + const val BYTE_MASK = 0xff + + // Local header (after the signature): offsets in the 26 fixed bytes. + const val LOCAL_FIXED = 26 + const val FLAGS = 2 + const val METHOD = 4 + const val CRC = 10 + const val COMPRESSED = 14 + const val SIZE = 18 + const val NAME_LENGTH = 22 + const val EXTRA_LENGTH = 24 + } +} diff --git a/app/src/main/java/com/pasich/encly/data/repository/NotesRepositoryImpl.kt b/app/src/main/java/com/pasich/encly/data/repository/NotesRepositoryImpl.kt index de2f04ee..0da10b58 100644 --- a/app/src/main/java/com/pasich/encly/data/repository/NotesRepositoryImpl.kt +++ b/app/src/main/java/com/pasich/encly/data/repository/NotesRepositoryImpl.kt @@ -1,24 +1,20 @@ package com.pasich.encly.data.repository +import com.pasich.encly.data.database.DatabaseLease import com.pasich.encly.data.database.DatabaseProvider import com.pasich.encly.data.model.Note import com.pasich.encly.data.model.NoteWithTag import com.pasich.encly.domain.repository.NotesRepository import kotlinx.coroutines.flow.Flow import javax.inject.Inject -import javax.inject.Singleton private const val TAG = "NotesRepository" -/** - * Resolves the DAO from the currently unlocked Room instance on every call. The encrypted - * database is closed on every app background, so a cached AppDatabase or DAO would point at a - * closed instance after the first re-lock/unlock cycle. - */ -@Singleton +/** Keeps the opening that created this repository; delayed calls cannot cross a re-lock. */ class NotesRepositoryImpl @Inject constructor(private val databaseProvider: DatabaseProvider) : NotesRepository { + private val lease: DatabaseLease? = databaseProvider.captureLease() - private fun dao() = databaseProvider.getDatabase().notesDao() + private fun dao() = databaseProvider.getDatabase(lease ?: error("Vault locked")).notesDao() override fun getAllNotesWithTag(): Flow> = daoFlow { dao().getAllNotesWithTags() } diff --git a/app/src/main/java/com/pasich/encly/data/repository/SettingsRepositoryImpl.kt b/app/src/main/java/com/pasich/encly/data/repository/SettingsRepositoryImpl.kt index 86ba1a8b..20a3193b 100644 --- a/app/src/main/java/com/pasich/encly/data/repository/SettingsRepositoryImpl.kt +++ b/app/src/main/java/com/pasich/encly/data/repository/SettingsRepositoryImpl.kt @@ -3,6 +3,7 @@ package com.pasich.encly.data.repository import com.pasich.encly.data.datasource.local.SettingsLocalDataSource import com.pasich.encly.domain.enums.NoteSortOption import com.pasich.encly.domain.model.FontStyleType +import com.pasich.encly.domain.model.NoteLineSpacing import com.pasich.encly.domain.model.ThemePalette import com.pasich.encly.domain.model.ThemeSettings import com.pasich.encly.domain.model.ThemeType @@ -13,6 +14,7 @@ import javax.inject.Inject import javax.inject.Singleton @Singleton +@Suppress("TooManyFunctions") // Implements the settings interface with one delegate per setting. class SettingsRepositoryImpl @Inject constructor(private val settingsLocalDataSource: SettingsLocalDataSource) : SettingsRepository { @@ -23,6 +25,7 @@ class SettingsRepositoryImpl @Inject constructor(private val settingsLocalDataSo override val simpleEditFlow: Flow = settingsLocalDataSource.simpleEditFlow override val fontSizeFlow: Flow = settingsLocalDataSource.fontSizeFlow override val fontStyleFlow: Flow = settingsLocalDataSource.fontStyleFlow + override val noteLineSpacingFlow: Flow = settingsLocalDataSource.noteLineSpacingFlow override val latestThemeSettings: ThemeSettings? get() = settingsLocalDataSource.latestThemeSettings @@ -49,4 +52,6 @@ class SettingsRepositoryImpl @Inject constructor(private val settingsLocalDataSo override suspend fun setFontSize(value: Int) = settingsLocalDataSource.setFontSize(value) override suspend fun setFontStyle(value: FontStyleType) = settingsLocalDataSource.setFontStyle(value) + + override suspend fun setNoteLineSpacing(value: NoteLineSpacing) = settingsLocalDataSource.setNoteLineSpacing(value) } diff --git a/app/src/main/java/com/pasich/encly/data/repository/TagsRepositoryImpl.kt b/app/src/main/java/com/pasich/encly/data/repository/TagsRepositoryImpl.kt index d37772d6..0241be9e 100644 --- a/app/src/main/java/com/pasich/encly/data/repository/TagsRepositoryImpl.kt +++ b/app/src/main/java/com/pasich/encly/data/repository/TagsRepositoryImpl.kt @@ -1,20 +1,20 @@ package com.pasich.encly.data.repository +import com.pasich.encly.data.database.DatabaseLease import com.pasich.encly.data.database.DatabaseProvider import com.pasich.encly.data.model.Tag import com.pasich.encly.domain.repository.TagsRepository import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map import javax.inject.Inject -import javax.inject.Singleton private const val TAG = "TagsRepository" -/** See [NotesRepositoryImpl] for why the DAO is resolved per call. */ -@Singleton +/** Bound to the opening that created it; see [NotesRepositoryImpl]. */ class TagsRepositoryImpl @Inject constructor(private val databaseProvider: DatabaseProvider) : TagsRepository { + private val lease: DatabaseLease? = databaseProvider.captureLease() - private fun dao() = databaseProvider.getDatabase().tagsDao() + private fun dao() = databaseProvider.getDatabase(lease ?: error("Vault locked")).tagsDao() override fun getTags(): Flow> = daoFlow { dao().getTags() }.map { tags -> tags.sortedBy { it.position } } diff --git a/app/src/main/java/com/pasich/encly/data/repository/TasksRepositoryImpl.kt b/app/src/main/java/com/pasich/encly/data/repository/TasksRepositoryImpl.kt index 55a8d1be..4167bfdd 100644 --- a/app/src/main/java/com/pasich/encly/data/repository/TasksRepositoryImpl.kt +++ b/app/src/main/java/com/pasich/encly/data/repository/TasksRepositoryImpl.kt @@ -1,20 +1,20 @@ package com.pasich.encly.data.repository +import com.pasich.encly.data.database.DatabaseLease import com.pasich.encly.data.database.DatabaseProvider import com.pasich.encly.data.model.Subtask import com.pasich.encly.data.model.Task import com.pasich.encly.domain.repository.TasksRepository import kotlinx.coroutines.flow.Flow import javax.inject.Inject -import javax.inject.Singleton private const val TAG = "TasksRepository" -/** See [NotesRepositoryImpl] for why the DAO is resolved per call. */ -@Singleton +/** Bound to the opening that created it; see [NotesRepositoryImpl]. */ class TasksRepositoryImpl @Inject constructor(private val databaseProvider: DatabaseProvider) : TasksRepository { + private val lease: DatabaseLease? = databaseProvider.captureLease() - private fun dao() = databaseProvider.getDatabase().tasksDao() + private fun dao() = databaseProvider.getDatabase(lease ?: error("Vault locked")).tasksDao() override fun getAllActiveTasks(): Flow> = daoFlow { dao().getAllActiveTasks() } diff --git a/app/src/main/java/com/pasich/encly/domain/model/NoteLineSpacing.kt b/app/src/main/java/com/pasich/encly/domain/model/NoteLineSpacing.kt new file mode 100644 index 00000000..e5557b6e --- /dev/null +++ b/app/src/main/java/com/pasich/encly/domain/model/NoteLineSpacing.kt @@ -0,0 +1,13 @@ +package com.pasich.encly.domain.model + +/** + * Editor text spacing, stored by ordinal. Append new choices without reordering these values: + * [DEFAULT] (0) is the long-standing look and [COMPACT] (1) the tight one, so a stored 0 or 1 + * keeps its meaning. Declaration order is therefore not display order (the selector shows + * Compact, Default, Comfortable). + */ +enum class NoteLineSpacing { + DEFAULT, + COMPACT, + COMFORTABLE, +} diff --git a/app/src/main/java/com/pasich/encly/domain/repository/SettingsRepository.kt b/app/src/main/java/com/pasich/encly/domain/repository/SettingsRepository.kt index c1cc947b..420c629b 100644 --- a/app/src/main/java/com/pasich/encly/domain/repository/SettingsRepository.kt +++ b/app/src/main/java/com/pasich/encly/domain/repository/SettingsRepository.kt @@ -2,11 +2,13 @@ package com.pasich.encly.domain.repository import com.pasich.encly.domain.enums.NoteSortOption import com.pasich.encly.domain.model.FontStyleType +import com.pasich.encly.domain.model.NoteLineSpacing import com.pasich.encly.domain.model.ThemePalette import com.pasich.encly.domain.model.ThemeSettings import com.pasich.encly.domain.model.ThemeType import kotlinx.coroutines.flow.Flow +@Suppress("TooManyFunctions") // One typed accessor per independent user setting. interface SettingsRepository { val themeSettingsFlow: Flow val isGridNoteList: Flow @@ -15,6 +17,7 @@ interface SettingsRepository { val simpleEditFlow: Flow val fontSizeFlow: Flow val fontStyleFlow: Flow + val noteLineSpacingFlow: Flow /** The theme last read from the store, or null before the first read. */ val latestThemeSettings: ThemeSettings? @@ -34,4 +37,5 @@ interface SettingsRepository { suspend fun setSimpleEdit(value: Boolean) suspend fun setFontSize(value: Int) suspend fun setFontStyle(value: FontStyleType) + suspend fun setNoteLineSpacing(value: NoteLineSpacing) } diff --git a/app/src/main/java/com/pasich/encly/presentation/components/HomeTaskWidget.kt b/app/src/main/java/com/pasich/encly/presentation/components/HomeTaskWidget.kt index 71096df8..778dbbab 100644 --- a/app/src/main/java/com/pasich/encly/presentation/components/HomeTaskWidget.kt +++ b/app/src/main/java/com/pasich/encly/presentation/components/HomeTaskWidget.kt @@ -10,7 +10,6 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource -import androidx.hilt.navigation.compose.hiltViewModel import com.pasich.encly.R import com.pasich.encly.presentation.components.tasks.PriorityValues import com.pasich.encly.presentation.designsystem.EnclyCard @@ -19,6 +18,7 @@ import com.pasich.encly.presentation.designsystem.EnclyInlineLink import com.pasich.encly.presentation.designsystem.EnclyTaskRow import com.pasich.encly.presentation.designsystem.SectionOverline import com.pasich.encly.presentation.viewmodel.TasksViewModel +import com.pasich.encly.presentation.viewmodel.enclyViewModel import com.pasich.encly.presentation.viewmodel.widgetTasks import com.pasich.encly.ui.theme.EnclyTheme @@ -32,7 +32,7 @@ fun HomeTaskWidget( modifier: Modifier = Modifier, onTasksClick: () -> Unit = {}, onNewTask: () -> Unit = onTasksClick, - viewModel: TasksViewModel = hiltViewModel(), + viewModel: TasksViewModel = enclyViewModel(), ) { val uiState by viewModel.uiState.collectAsState() val preview = remember(uiState.activeTasks) { widgetTasks(uiState.activeTasks) } diff --git a/app/src/main/java/com/pasich/encly/presentation/components/editNote/NoteBottomBar.kt b/app/src/main/java/com/pasich/encly/presentation/components/editNote/NoteBottomBar.kt index c9467aa1..8a71853d 100644 --- a/app/src/main/java/com/pasich/encly/presentation/components/editNote/NoteBottomBar.kt +++ b/app/src/main/java/com/pasich/encly/presentation/components/editNote/NoteBottomBar.kt @@ -39,7 +39,6 @@ import androidx.compose.ui.platform.LocalView import androidx.compose.ui.res.stringResource import androidx.core.view.WindowCompat import androidx.core.view.WindowInsetsCompat -import androidx.hilt.navigation.compose.hiltViewModel import com.pasich.encly.R import com.pasich.encly.dynamicBlocks.BlockType import com.pasich.encly.presentation.designsystem.EnclyEditorToolbar @@ -52,6 +51,7 @@ import com.pasich.encly.presentation.editor.mainBlockTools import com.pasich.encly.presentation.editor.moreBlockTools import com.pasich.encly.presentation.editor.state.toolType import com.pasich.encly.presentation.viewmodel.EditNoteViewModel +import com.pasich.encly.presentation.viewmodel.enclyViewModel import com.pasich.encly.ui.theme.EnclyTheme import kotlinx.coroutines.launch @@ -64,7 +64,7 @@ import kotlinx.coroutines.launch @Composable fun NoteBottomBar( modifier: Modifier = Modifier, - viewModel: EditNoteViewModel = hiltViewModel(), + viewModel: EditNoteViewModel = enclyViewModel(), simpleEdit: Boolean = false, ) { var showMore by rememberSaveable { mutableStateOf(false) } @@ -110,6 +110,7 @@ fun NoteBottomBar( blockTools = BlockMoveTools( onMoveUp = if (canMoveUp) ({ viewModel.moveBlock(up = true) }) else null, onMoveDown = if (canMoveDown) ({ viewModel.moveBlock(up = false) }) else null, + onDuplicate = viewModel::duplicateInteractedBlock, onDelete = if (canDelete) viewModel::removeInteractedBlock else null, ), onApply = { viewModel.applyTool(it, exact = true) }, @@ -131,8 +132,13 @@ fun NoteBottomBar( /** Undo and redo; a null action shows its button disabled. */ private class HistoryTools(val onUndo: (() -> Unit)?, val onRedo: (() -> Unit)?) -/** Moving and deleting the block the user works on; a null action shows its button disabled. */ -private class BlockMoveTools(val onMoveUp: (() -> Unit)?, val onMoveDown: (() -> Unit)?, val onDelete: (() -> Unit)?) +/** Moving, duplicating and deleting the block the user works on; a null action shows its button disabled. */ +private class BlockMoveTools( + val onMoveUp: (() -> Unit)?, + val onMoveDown: (() -> Unit)?, + val onDuplicate: (() -> Unit)?, + val onDelete: (() -> Unit)?, +) /** "Add block", then the block tools, the one of the block the user works on marked active. */ @Composable @@ -266,7 +272,7 @@ private fun HistoryButtons(history: HistoryTools) { ) } -/** Behind "Add block": close, paragraph and heading levels, undo and redo, move and delete. */ +/** Behind "Add block": close, paragraph and heading levels, undo and redo, move, duplicate and delete. */ @Composable private fun MoreTools( history: HistoryTools, @@ -299,6 +305,11 @@ private fun MoreTools( contentDescription = stringResource(R.string.block_move_down), onClick = blockTools.onMoveDown, ) + EnclyToolButton( + icon = EnclyIcons.Duplicate, + contentDescription = stringResource(R.string.block_duplicate), + onClick = blockTools.onDuplicate, + ) EnclyToolButton( icon = EnclyIcons.Trash, contentDescription = stringResource(R.string.delete_block), diff --git a/app/src/main/java/com/pasich/encly/presentation/components/settings/SettingsBuilder.kt b/app/src/main/java/com/pasich/encly/presentation/components/settings/SettingsBuilder.kt index 2e7a2998..ec52ac69 100644 --- a/app/src/main/java/com/pasich/encly/presentation/components/settings/SettingsBuilder.kt +++ b/app/src/main/java/com/pasich/encly/presentation/components/settings/SettingsBuilder.kt @@ -14,6 +14,7 @@ data class SettingsNavigation( val onAppearance: () -> Unit, val onSecurity: () -> Unit, val onBackup: () -> Unit, + val onImportNotes: () -> Unit, val onAbout: () -> Unit, val onFaq: () -> Unit, ) @@ -44,6 +45,14 @@ class SettingsBuilder { ) { navigation.onBackup() }, + single( + R.string.settings_import, + R.string.import_title, + R.string.import_settings_subtitle, + EnclyIcons.File, + ) { + navigation.onImportNotes() + }, SettingsCategory( titleRes = R.string.about, items = listOf( diff --git a/app/src/main/java/com/pasich/encly/presentation/components/tasks/SubtaskTree.kt b/app/src/main/java/com/pasich/encly/presentation/components/tasks/SubtaskTree.kt index f5dd5a25..5ae10119 100644 --- a/app/src/main/java/com/pasich/encly/presentation/components/tasks/SubtaskTree.kt +++ b/app/src/main/java/com/pasich/encly/presentation/components/tasks/SubtaskTree.kt @@ -152,6 +152,7 @@ private fun rememberAnimationsEnabled(): Boolean { * [edit] is the list's inline field when it belongs to this task; [dimmed] for a task that is done. */ @Composable +@Suppress("LongMethod") // Reorderable row keeps its drag, accessibility, and inline-edit scope together. internal fun SubtaskTree( taskId: Long, subtasks: List, @@ -181,34 +182,36 @@ internal fun SubtaskTree( onMove = { haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove) }, ) { index, subtask, dragging -> key(subtask.id) { - val renaming = edit != null && edit.target == InlineSubtaskTarget.Rename(taskId, subtask.id) - val reorder = Modifier.longPressDraggableHandle( - enabled = enabled && !renaming, - onDragStarted = { haptics.performHapticFeedback(HapticFeedbackType.LongPress) }, - ) - // Given to the title: the node TalkBack focuses (a plain row is not one). - val moves = moveActions( - moveUp = { move(index, index - 1) }.takeIf { index > 0 }, - moveDown = { move(index, index + 1) }.takeIf { index < rows.value.lastIndex }, - ) - if (renaming) { - RenameField(subtask, edit, actions, enabled) - } else { - EnclySubtaskRow( - title = subtask.title, - checked = subtask.isCompleted, - onCheckedChange = { actions.onToggle(subtask, it) }, - // The add leaf always closes the tree. - isLast = false, - modifier = reorder, - dimmed = dimmed, - enabled = enabled, - onClick = { actions.onStartRenaming(subtask) }, - onClickLabel = stringResource(R.string.edit), - customActions = moves, - dragging = dragging, - trailing = chevron.takeIf { index == 0 }, + ReorderableItem { + val renaming = edit != null && edit.target == InlineSubtaskTarget.Rename(taskId, subtask.id) + val reorder = Modifier.longPressDraggableHandle( + enabled = enabled && !renaming, + onDragStarted = { haptics.performHapticFeedback(HapticFeedbackType.LongPress) }, ) + // Given to the title: the node TalkBack focuses (a plain row is not one). + val moves = moveActions( + moveUp = { move(index, index - 1) }.takeIf { index > 0 }, + moveDown = { move(index, index + 1) }.takeIf { index < rows.value.lastIndex }, + ) + if (renaming) { + RenameField(subtask, edit, actions, enabled) + } else { + EnclySubtaskRow( + title = subtask.title, + checked = subtask.isCompleted, + onCheckedChange = { actions.onToggle(subtask, it) }, + // The add leaf always closes the tree. + isLast = false, + modifier = reorder, + dimmed = dimmed, + enabled = enabled, + onClick = { actions.onStartRenaming(subtask) }, + onClickLabel = stringResource(R.string.edit), + customActions = moves, + dragging = dragging, + trailing = chevron.takeIf { index == 0 }, + ) + } } } } diff --git a/app/src/main/java/com/pasich/encly/presentation/designsystem/CommandMenuItem.kt b/app/src/main/java/com/pasich/encly/presentation/designsystem/CommandMenuItem.kt new file mode 100644 index 00000000..ece4c5d4 --- /dev/null +++ b/app/src/main/java/com/pasich/encly/presentation/designsystem/CommandMenuItem.kt @@ -0,0 +1,94 @@ +package com.pasich.encly.presentation.designsystem + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.selected +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp +import com.pasich.encly.ui.theme.EnclyTheme + +/** One row of an [EnclyCommandMenu]. */ +data class CommandMenuItem(val icon: ImageVector, val label: String) + +private val MenuMaxHeight = 240.dp +private val MenuMinWidth = 208.dp +private val MenuMaxWidth = 288.dp +private val MenuElevation = 6.dp + +/** + * A small floating list of commands (the editor's slash picker): rows at least 48 dp tall, the + * [highlighted] one tinted so a hardware keyboard can see where Enter lands, scrolling when there + * are more than fit. [title] describes the list to TalkBack. + */ +@Composable +fun EnclyCommandMenu( + items: List, + highlighted: Int, + title: String, + onSelect: (Int) -> Unit, + modifier: Modifier = Modifier, +) { + val colors = MaterialTheme.colorScheme + val listState = rememberLazyListState() + LaunchedEffect(highlighted) { + if (highlighted in items.indices) listState.animateScrollToItem(highlighted) + } + Surface( + shape = MaterialTheme.shapes.medium, + color = colors.surfaceContainerHigh, + shadowElevation = MenuElevation, + modifier = modifier + .widthIn(min = MenuMinWidth, max = MenuMaxWidth) + .semantics { contentDescription = title }, + ) { + LazyColumn(state = listState, modifier = Modifier.heightIn(max = MenuMaxHeight)) { + itemsIndexed(items) { index, item -> + val active = index == highlighted + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(EnclyTheme.spacing.s), + modifier = Modifier + .fillMaxWidth() + .background(if (active) colors.primaryContainer else colors.surfaceContainerHigh) + .clickable(role = Role.Button) { onSelect(index) } + .heightIn(min = EnclyTheme.spacing.minTouchTarget) + .padding(horizontal = EnclyTheme.spacing.m) + .semantics { selected = active }, + ) { + Icon( + item.icon, + contentDescription = null, + tint = if (active) colors.onPrimaryContainer else colors.onSurfaceVariant, + modifier = Modifier.size(EnclyTheme.spacing.iconMedium), + ) + Text( + text = item.label, + style = MaterialTheme.typography.labelLarge, + color = if (active) colors.onPrimaryContainer else colors.onSurface, + ) + } + } + } + } +} diff --git a/app/src/main/java/com/pasich/encly/presentation/designsystem/Editor.kt b/app/src/main/java/com/pasich/encly/presentation/designsystem/Editor.kt index 82aacb5d..6885c46d 100644 --- a/app/src/main/java/com/pasich/encly/presentation/designsystem/Editor.kt +++ b/app/src/main/java/com/pasich/encly/presentation/designsystem/Editor.kt @@ -25,6 +25,7 @@ import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.role import androidx.compose.ui.semantics.selected import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.pasich.encly.ui.theme.EnclyTheme @@ -107,7 +108,8 @@ private val BlockInset = 4.dp * The inner padding of one editor block, 4/8. The editor column sits [BlockBleed] inside the * gutter, so text lines up with the title. No outline while editing: the caret is the focus mark. */ -fun Modifier.editorBlockFrame(): Modifier = padding(horizontal = BlockBleed, vertical = BlockInset) +fun Modifier.editorBlockFrame(verticalInset: Dp? = null): Modifier = + padding(horizontal = BlockBleed, vertical = verticalInset ?: BlockInset) /** A thin vertical rule between groups of toolbar buttons. */ @Composable @@ -118,3 +120,6 @@ fun EnclyToolbarRule(modifier: Modifier = Modifier) { .background(MaterialTheme.colorScheme.outlineVariant), ) } + +/** Room the editor toolbar takes above the keyboard; floating menus keep clear of it. */ +val EnclyToolbarReserve = 72.dp diff --git a/app/src/main/java/com/pasich/encly/presentation/designsystem/NoteSpacing.kt b/app/src/main/java/com/pasich/encly/presentation/designsystem/NoteSpacing.kt new file mode 100644 index 00000000..950cf109 --- /dev/null +++ b/app/src/main/java/com/pasich/encly/presentation/designsystem/NoteSpacing.kt @@ -0,0 +1,50 @@ +package com.pasich.encly.presentation.designsystem + +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.dp +import com.pasich.encly.domain.model.NoteLineSpacing + +// What each reading density means in the editor. Presentation only: the stored note is the same +// whatever is chosen. DEFAULT returns the exact values the editor always used. + +private const val COMFORTABLE_LINE_FACTOR = 1.25f +private val FRAME_INSET_DEFAULT = 4.dp +private val FRAME_INSET_COMFORTABLE = 6.dp +private val BLOCK_GAP_EXTRA_COMFORTABLE = 4.dp +private val LIST_ITEM_GAP_COMFORTABLE = 8.dp + +/** Whether lines sit as close as the font allows (no leading, no gaps between blocks). */ +internal val NoteLineSpacing.isCompact: Boolean get() = this == NoteLineSpacing.COMPACT + +/** + * Line height of a body text: the font size itself when compact, the theme's [normal] height by + * default, and a quarter more when comfortable. + */ +internal fun NoteLineSpacing.lineHeight(fontSize: TextUnit, normal: TextUnit): TextUnit = when (this) { + NoteLineSpacing.COMPACT -> fontSize + NoteLineSpacing.DEFAULT -> normal + NoteLineSpacing.COMFORTABLE -> normal * COMFORTABLE_LINE_FACTOR +} + +/** Vertical padding inside the frame of a paragraph, list or quote. */ +internal val NoteLineSpacing.frameInset: Dp + get() = when (this) { + NoteLineSpacing.COMPACT -> 0.dp + NoteLineSpacing.DEFAULT -> FRAME_INSET_DEFAULT + NoteLineSpacing.COMFORTABLE -> FRAME_INSET_COMFORTABLE + } + +/** The gap between the editor's blocks, given the default gap [base]. */ +internal fun NoteLineSpacing.blockGap(base: Dp): Dp = when (this) { + NoteLineSpacing.COMPACT -> 0.dp + NoteLineSpacing.DEFAULT -> base + NoteLineSpacing.COMFORTABLE -> base + BLOCK_GAP_EXTRA_COMFORTABLE +} + +/** The gap between the rows of a list; a checklist's rows are tall enough already. */ +internal fun NoteLineSpacing.listItemGap(checklist: Boolean, base: Dp): Dp = when { + this == NoteLineSpacing.COMPACT || (checklist && this == NoteLineSpacing.DEFAULT) -> 0.dp + this == NoteLineSpacing.COMFORTABLE -> LIST_ITEM_GAP_COMFORTABLE + else -> base +} diff --git a/app/src/main/java/com/pasich/encly/presentation/designsystem/TextFields.kt b/app/src/main/java/com/pasich/encly/presentation/designsystem/TextFields.kt index c648085f..939a67da 100644 --- a/app/src/main/java/com/pasich/encly/presentation/designsystem/TextFields.kt +++ b/app/src/main/java/com/pasich/encly/presentation/designsystem/TextFields.kt @@ -138,14 +138,20 @@ private fun ValidMark() { } } -/** The notes search field: 48 dp, fully round, `surfaceContainer`, no border. */ +/** + * The notes search field: 48 dp, fully round, `surfaceContainer`, no border. The keyboard's Search + * action runs [onSubmit], else puts the keyboard away; [fieldModifier] reaches the text field itself + * (to focus it). + */ @Composable fun EnclySearchField( value: String, onValueChange: (String) -> Unit, placeholder: String, modifier: Modifier = Modifier, + fieldModifier: Modifier = Modifier, trailing: @Composable (() -> Unit)? = null, + onSubmit: (() -> Unit)? = null, ) { val colors = MaterialTheme.colorScheme val keyboard = LocalSoftwareKeyboardController.current @@ -162,13 +168,7 @@ fun EnclySearchField( horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.padding( start = EnclyTheme.spacing.m, - end = if (trailing == - null - ) { - EnclyTheme.spacing.m - } else { - 4.dp - }, + end = if (trailing == null) EnclyTheme.spacing.m else 4.dp, ), ) { Icon( @@ -185,9 +185,10 @@ fun EnclySearchField( cursorBrush = SolidColor(colors.primary), // Results filter as the user types; Search only puts the keyboard away. keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), - keyboardActions = KeyboardActions(onSearch = { keyboard?.hide() }), + keyboardActions = KeyboardActions(onSearch = { onSubmit?.invoke() ?: keyboard?.hide() }), modifier = Modifier .weight(1f) + .then(fieldModifier) .semantics { contentDescription = searchDescription }, decorationBox = { inner -> Box { diff --git a/app/src/main/java/com/pasich/encly/presentation/dialogs/EditNoteBottomSheet.kt b/app/src/main/java/com/pasich/encly/presentation/dialogs/EditNoteBottomSheet.kt index 8e565662..c79a105a 100644 --- a/app/src/main/java/com/pasich/encly/presentation/dialogs/EditNoteBottomSheet.kt +++ b/app/src/main/java/com/pasich/encly/presentation/dialogs/EditNoteBottomSheet.kt @@ -5,19 +5,23 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource -import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.pasich.encly.R +import com.pasich.encly.domain.model.NoteLineSpacing import com.pasich.encly.presentation.components.FontSizeSlider import com.pasich.encly.presentation.designsystem.EnclyBottomSheet import com.pasich.encly.presentation.designsystem.EnclyGroupDivider import com.pasich.encly.presentation.designsystem.EnclyIcons +import com.pasich.encly.presentation.designsystem.EnclyRadioRow import com.pasich.encly.presentation.designsystem.EnclySheetRow +import com.pasich.encly.presentation.designsystem.SectionOverline import com.pasich.encly.presentation.viewmodel.EditNoteViewModel +import com.pasich.encly.presentation.viewmodel.enclyViewModel enum class EditNoteBottomSheetAction { CLOSE_NO_SAVE, DUPLICATE, + FIND, TRASH, } @@ -31,10 +35,11 @@ fun EditNoteBottomSheet( isVisible: Boolean, onDismiss: () -> Unit, modifier: Modifier = Modifier, - viewModel: EditNoteViewModel = hiltViewModel(), + viewModel: EditNoteViewModel = enclyViewModel(), onAction: (EditNoteBottomSheetAction) -> Unit = {}, ) { val fontSize by viewModel.fontSize.collectAsStateWithLifecycle() + val lineSpacing by viewModel.noteLineSpacing.collectAsStateWithLifecycle() if (isVisible) { EnclyBottomSheet(onDismissRequest = onDismiss, modifier = modifier) { @@ -42,7 +47,17 @@ fun EditNoteBottomSheet( currentSize = fontSize, onSizeChange = { newSize -> viewModel.updateFontSize(newSize) }, ) + SectionOverline(stringResource(R.string.note_line_spacing_title)) + LineSpacingChoices(selected = lineSpacing, onSelect = viewModel::updateNoteLineSpacing) EnclyGroupDivider() + EnclySheetRow( + title = stringResource(R.string.find_in_note), + icon = EnclyIcons.Search, + onClick = { + onAction(EditNoteBottomSheetAction.FIND) + onDismiss() + }, + ) EnclySheetRow( title = stringResource(id = R.string.duplicate), icon = EnclyIcons.Duplicate, @@ -72,3 +87,21 @@ fun EditNoteBottomSheet( } } } + +/** The reading densities in display order; the enum's own order is fixed by what is stored. */ +private val lineSpacingOrder = listOf( + NoteLineSpacing.COMPACT to R.string.note_line_spacing_compact, + NoteLineSpacing.DEFAULT to R.string.note_line_spacing_default, + NoteLineSpacing.COMFORTABLE to R.string.note_line_spacing_comfortable, +) + +@Composable +private fun LineSpacingChoices(selected: NoteLineSpacing, onSelect: (NoteLineSpacing) -> Unit) { + lineSpacingOrder.forEach { (spacing, label) -> + EnclyRadioRow( + title = stringResource(label), + selected = selected == spacing, + onClick = { onSelect(spacing) }, + ) + } +} diff --git a/app/src/main/java/com/pasich/encly/presentation/dialogs/NoteCardBottomSheet.kt b/app/src/main/java/com/pasich/encly/presentation/dialogs/NoteCardBottomSheet.kt index e3e0169f..66507ccd 100644 --- a/app/src/main/java/com/pasich/encly/presentation/dialogs/NoteCardBottomSheet.kt +++ b/app/src/main/java/com/pasich/encly/presentation/dialogs/NoteCardBottomSheet.kt @@ -30,7 +30,6 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp -import androidx.hilt.navigation.compose.hiltViewModel import com.pasich.encly.R import com.pasich.encly.data.model.NoteWithTag import com.pasich.encly.presentation.designsystem.EnclyBottomSheet @@ -42,6 +41,7 @@ import com.pasich.encly.presentation.designsystem.EnclySheetRow import com.pasich.encly.presentation.designsystem.EnclyTextButton import com.pasich.encly.presentation.designsystem.EnclyTextField import com.pasich.encly.presentation.viewmodel.TagListViewModel +import com.pasich.encly.presentation.viewmodel.enclyViewModel import com.pasich.encly.ui.theme.EnclyTheme import com.pasich.encly.utils.formatNoteDate import java.util.Date @@ -134,7 +134,7 @@ private fun NoteActionHeader(item: NoteWithTag) { private fun TagChips( selectedTagId: Long, onSelect: (Long) -> Unit, - tagListViewModel: TagListViewModel = hiltViewModel(), + tagListViewModel: TagListViewModel = enclyViewModel(), ) { val state by tagListViewModel.state.collectAsState() val gutter = EnclyTheme.spacing.gutter diff --git a/app/src/main/java/com/pasich/encly/presentation/dialogs/NotesSortBottomSheet.kt b/app/src/main/java/com/pasich/encly/presentation/dialogs/NotesSortBottomSheet.kt index 76be1095..135d7fdd 100644 --- a/app/src/main/java/com/pasich/encly/presentation/dialogs/NotesSortBottomSheet.kt +++ b/app/src/main/java/com/pasich/encly/presentation/dialogs/NotesSortBottomSheet.kt @@ -6,7 +6,6 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.res.stringResource -import androidx.hilt.navigation.compose.hiltViewModel import com.pasich.encly.R import com.pasich.encly.domain.enums.NoteSortOption import com.pasich.encly.presentation.designsystem.EnclyBottomSheet @@ -16,6 +15,7 @@ import com.pasich.encly.presentation.designsystem.EnclyRadio import com.pasich.encly.presentation.designsystem.EnclySheetRow import com.pasich.encly.presentation.viewmodel.NoteListEvent import com.pasich.encly.presentation.viewmodel.NoteListViewModel +import com.pasich.encly.presentation.viewmodel.enclyViewModel /** View options of the notes list: the sort order, and the switch between list and grid. */ @OptIn(ExperimentalMaterial3Api::class) @@ -26,7 +26,7 @@ fun NotesSortBottomSheet( isGrid: Boolean, onToggleView: () -> Unit, isBottomSheetVisible: Boolean = false, - noteListViewModel: NoteListViewModel = hiltViewModel(), + noteListViewModel: NoteListViewModel = enclyViewModel(), ) { val state by noteListViewModel.state.collectAsState() diff --git a/app/src/main/java/com/pasich/encly/presentation/dialogs/blocks/ActionBlockDialog.kt b/app/src/main/java/com/pasich/encly/presentation/dialogs/blocks/ActionBlockDialog.kt index 1a47b015..e11d933f 100644 --- a/app/src/main/java/com/pasich/encly/presentation/dialogs/blocks/ActionBlockDialog.kt +++ b/app/src/main/java/com/pasich/encly/presentation/dialogs/blocks/ActionBlockDialog.kt @@ -2,6 +2,7 @@ package com.pasich.encly.presentation.dialogs.blocks sealed class ActionBlockDialog { data class Move(val move: Int) : ActionBlockDialog() // 1 - top, 2 - bottom + object Duplicate : ActionBlockDialog() object Delete : ActionBlockDialog() object OpenLink : ActionBlockDialog() object CopyLink : ActionBlockDialog() diff --git a/app/src/main/java/com/pasich/encly/presentation/dialogs/blocks/BlockEditRows.kt b/app/src/main/java/com/pasich/encly/presentation/dialogs/blocks/BlockEditRows.kt index 262d4461..1693cdbd 100644 --- a/app/src/main/java/com/pasich/encly/presentation/dialogs/blocks/BlockEditRows.kt +++ b/app/src/main/java/com/pasich/encly/presentation/dialogs/blocks/BlockEditRows.kt @@ -6,7 +6,7 @@ import com.pasich.encly.R import com.pasich.encly.presentation.designsystem.EnclyIcons import com.pasich.encly.presentation.designsystem.EnclySheetRow -/** Move up, move down and delete (confirmed by a second tap), shared by the block sheets. */ +/** Move up, move down, duplicate and delete (confirmed by a second tap), shared by the block sheets. */ @Composable internal fun BlockEditRows(settings: SettingsBlockDialog, onAction: (ActionBlockDialog) -> Unit) { EnclySheetRow( @@ -21,6 +21,11 @@ internal fun BlockEditRows(settings: SettingsBlockDialog, onAction: (ActionBlock enabled = settings.blockMove != 2, onClick = { onAction(ActionBlockDialog.Move(2)) }, ) + EnclySheetRow( + title = stringResource(R.string.block_duplicate), + icon = EnclyIcons.Duplicate, + onClick = { onAction(ActionBlockDialog.Duplicate) }, + ) EnclySheetRow( title = stringResource(id = R.string.delete_block), icon = EnclyIcons.Trash, diff --git a/app/src/main/java/com/pasich/encly/presentation/dialogs/tasks/SubtaskEditor.kt b/app/src/main/java/com/pasich/encly/presentation/dialogs/tasks/SubtaskEditor.kt index a0dff6d8..4a0babf3 100644 --- a/app/src/main/java/com/pasich/encly/presentation/dialogs/tasks/SubtaskEditor.kt +++ b/app/src/main/java/com/pasich/encly/presentation/dialogs/tasks/SubtaskEditor.kt @@ -44,7 +44,7 @@ import com.pasich.encly.presentation.viewmodel.SubtaskDraft import com.pasich.encly.presentation.viewmodel.SubtaskDrafts import com.pasich.encly.ui.theme.EnclyTheme import sh.calvin.reorderable.ReorderableColumn -import sh.calvin.reorderable.ReorderableScope +import sh.calvin.reorderable.ReorderableListItemScope /** Longest sub-task title, in the sheet and in the list's inline field alike. */ internal const val SUBTASK_TITLE_MAX_LENGTH = 100 @@ -137,12 +137,14 @@ internal fun SubtaskEditor(state: SubtaskListState) { onMove = { haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove) }, ) { index, subtask, _ -> key(subtask.key) { - SubtaskRow( - subtask = subtask, - state = state, - moveUp = { state.move(index, index - 1) }.takeIf { index > 0 }, - moveDown = { state.move(index, index + 1) }.takeIf { index < subtasks.lastIndex }, - ) + ReorderableItem { + SubtaskRow( + subtask = subtask, + state = state, + moveUp = { state.move(index, index - 1) }.takeIf { index > 0 }, + moveDown = { state.move(index, index + 1) }.takeIf { index < subtasks.lastIndex }, + ) + } } } NewSubtaskRow(title = state.newTitle, onTitleChange = state::changeNewTitle, onAdd = state::add) @@ -150,7 +152,7 @@ internal fun SubtaskEditor(state: SubtaskListState) { } @Composable -private fun ReorderableScope.SubtaskRow( +private fun ReorderableListItemScope.SubtaskRow( subtask: SubtaskDraft, state: SubtaskListState, moveUp: (() -> Unit)?, diff --git a/app/src/main/java/com/pasich/encly/presentation/drawer/MainDrawer.kt b/app/src/main/java/com/pasich/encly/presentation/drawer/MainDrawer.kt index 00e097d7..350eb1f0 100644 --- a/app/src/main/java/com/pasich/encly/presentation/drawer/MainDrawer.kt +++ b/app/src/main/java/com/pasich/encly/presentation/drawer/MainDrawer.kt @@ -21,7 +21,6 @@ import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp -import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.pasich.encly.BuildConfig import com.pasich.encly.R @@ -32,6 +31,7 @@ import com.pasich.encly.presentation.navigation.DrawerNavItem import com.pasich.encly.presentation.navigation.NavRoutes import com.pasich.encly.presentation.navigation.drawerNavItems import com.pasich.encly.presentation.viewmodel.StatisticViewModel +import com.pasich.encly.presentation.viewmodel.enclyViewModel import com.pasich.encly.ui.theme.EnclyTheme import kotlinx.coroutines.delay import kotlinx.coroutines.launch @@ -46,7 +46,7 @@ fun MainDrawer( onItemClick: (DrawerNavItem) -> Unit, modifier: Modifier = Modifier, topInset: Dp = 0.dp, - statisticViewModel: StatisticViewModel = hiltViewModel(), + statisticViewModel: StatisticViewModel = enclyViewModel(), ) { val totalTags by statisticViewModel.totalTagsCreated.collectAsStateWithLifecycle() val openTasks by statisticViewModel.openTasksCount.collectAsStateWithLifecycle() diff --git a/app/src/main/java/com/pasich/encly/presentation/editor/BlockActions.kt b/app/src/main/java/com/pasich/encly/presentation/editor/BlockActions.kt index 6e1104d8..2fd17b8f 100644 --- a/app/src/main/java/com/pasich/encly/presentation/editor/BlockActions.kt +++ b/app/src/main/java/com/pasich/encly/presentation/editor/BlockActions.kt @@ -7,6 +7,9 @@ import com.pasich.encly.presentation.editor.state.BlockRemoveAction import com.pasich.encly.presentation.editor.state.FocusRequest import com.pasich.encly.presentation.editor.state.LineBreak import com.pasich.encly.presentation.editor.state.Shortcut +import com.pasich.encly.presentation.editor.state.SlashCommand +import com.pasich.encly.presentation.editor.state.SlashMenu +import kotlinx.coroutines.flow.StateFlow /** * What a block composable can do. Content edits go through [onTextChanged], @@ -61,9 +64,45 @@ interface BlockActions { */ fun registerFieldsFocusTarget(requestFocus: (FocusRequest) -> Unit): () -> Unit + /** What Find in this note tints in this block's fields. */ + val highlights: FindHighlights + + /** The slash picker of this paragraph. */ + val slash: SlashActions + + /** + * This paragraph now reads [text]. [started] when a "/" was just typed into the empty paragraph; + * otherwise it keeps an open slash picker in step with the text. + */ + fun onSlashTyped(text: String, started: Boolean) + /** Focuses the next block; false when this is the last one. */ fun navigateToNext(): Boolean /** Focuses the focusable block above; false when there is none. */ fun navigateToPrevious(): Boolean } + +/** What a paragraph's slash picker can do. The editor decides whether it may open (Simple Edit, read-only). */ +interface SlashActions { + /** The paragraph this picker belongs to: the menu shows only when it is open on this one. */ + val blockId: String + + /** The picker as it is now; null while none is open on any paragraph. */ + val menu: StateFlow + + /** The "/" is committed text: the picker may show. */ + fun commit() + + /** Turns this paragraph into [command]'s block. */ + fun select(command: SlashCommand) + + /** Closes the picker; the typed text stays. */ + fun dismiss() + + /** Moves the highlight [delta] places. */ + fun moveHighlight(delta: Int) + + /** The localized labels the query is matched against. */ + fun setLabels(labels: Map) +} diff --git a/app/src/main/java/com/pasich/encly/presentation/editor/BlockActionsImpl.kt b/app/src/main/java/com/pasich/encly/presentation/editor/BlockActionsImpl.kt index 1175e591..c887e018 100644 --- a/app/src/main/java/com/pasich/encly/presentation/editor/BlockActionsImpl.kt +++ b/app/src/main/java/com/pasich/encly/presentation/editor/BlockActionsImpl.kt @@ -9,7 +9,10 @@ import com.pasich.encly.presentation.editor.state.BlockRemoveAction import com.pasich.encly.presentation.editor.state.FocusRequest import com.pasich.encly.presentation.editor.state.LineBreak import com.pasich.encly.presentation.editor.state.Shortcut +import com.pasich.encly.presentation.editor.state.SlashCommand +import com.pasich.encly.presentation.editor.state.SlashMenu import com.pasich.encly.presentation.viewmodel.EditNoteViewModel +import kotlinx.coroutines.flow.StateFlow /** * Block actions bound to one [block]. Its position is looked up when an action runs, so an @@ -21,6 +24,7 @@ internal class BlockActionsImpl( private val block: Block, private val viewModel: EditNoteViewModel, private val focusRegistry: BlockFocusRegistry, + override val highlights: FindHighlights, ) : BlockActions { private val index: Int get() = viewModel.indexOfBlock(block) @@ -72,6 +76,26 @@ internal class BlockActionsImpl( override fun registerFieldsFocusTarget(requestFocus: (FocusRequest) -> Unit): () -> Unit = focusRegistry.registerFieldsFocusTarget(block.id, requestFocus) + override val slash: SlashActions = object : SlashActions { + override val blockId: String get() = block.id + + override val menu: StateFlow get() = viewModel.slashPicker.menu + + override fun commit() = viewModel.slashPicker.commit() + + override fun select(command: SlashCommand) { + viewModel.applySlash(block, command) + } + + override fun dismiss() = viewModel.slashPicker.dismiss() + + override fun moveHighlight(delta: Int) = viewModel.slashPicker.moveHighlight(delta) + + override fun setLabels(labels: Map) = viewModel.slashPicker.setLabels(labels) + } + + override fun onSlashTyped(text: String, started: Boolean) = viewModel.onSlashTyped(block, text, started) + override fun navigateToNext(): Boolean = viewModel.moveFocusTo(focusRegistry.nextBlock(block.id), focusRegistry, cursorToEnd = false) diff --git a/app/src/main/java/com/pasich/encly/presentation/editor/EditorBlocksHost.kt b/app/src/main/java/com/pasich/encly/presentation/editor/EditorBlocksHost.kt index 10266ae5..5672d1d7 100644 --- a/app/src/main/java/com/pasich/encly/presentation/editor/EditorBlocksHost.kt +++ b/app/src/main/java/com/pasich/encly/presentation/editor/EditorBlocksHost.kt @@ -26,6 +26,7 @@ import com.pasich.encly.R import com.pasich.encly.domain.enums.BottomSheetsOpenType import com.pasich.encly.dynamicBlocks.Block import com.pasich.encly.presentation.designsystem.editorBlockFrame +import com.pasich.encly.presentation.designsystem.frameInset import com.pasich.encly.presentation.editor.blocks.HBlock import com.pasich.encly.presentation.editor.blocks.LinkBlock import com.pasich.encly.presentation.editor.blocks.ListBlock @@ -35,6 +36,7 @@ import com.pasich.encly.presentation.editor.blocks.TextBlock import com.pasich.encly.presentation.editor.focus.BlockFocusRegistry import com.pasich.encly.presentation.editor.focus.RegisterFocusRequester import com.pasich.encly.presentation.editor.state.BlockRemoveAction +import com.pasich.encly.presentation.screen.editnote.LocalNoteLineSpacing import com.pasich.encly.presentation.viewmodel.EditNoteViewModel import com.pasich.encly.ui.theme.EnclyTheme @@ -42,6 +44,7 @@ import com.pasich.encly.ui.theme.EnclyTheme class EditorBlocksHost( val viewModel: EditNoteViewModel, val focusRegistry: BlockFocusRegistry, + val highlights: FindHighlights, val onOpenSheet: (BottomSheetsOpenType, Block) -> Unit, ) @@ -50,20 +53,23 @@ class EditorBlocksHost( * lays out only what is on screen, and remembered field state, the focus target and the cursor * callback stay with their block when others are inserted, removed or moved around it. The * editor's [blocks] list is read here, so structural changes update the items. Below the last - * block a tap area focuses (or adds) a trailing paragraph. + * block a tap area focuses (or adds) a trailing paragraph; [afterBlocks], if given, is shown + * between the last block and that area. */ fun LazyListScope.editorBlocks( host: EditorBlocksHost, blocks: List, isLocked: Boolean, editingLinkIds: Set, + afterBlocks: (@Composable () -> Unit)? = null, ) { itemsIndexed( items = blocks, key = { _, block -> block.id }, contentType = { _, block -> block::class.simpleName }, ) { index, block -> - val blockActions = remember(block, host) { BlockActionsImpl(block, host.viewModel, host.focusRegistry) } + val blockActions = + remember(block, host) { BlockActionsImpl(block, host.viewModel, host.focusRegistry, host.highlights) } val callbacks = remember(block, host) { EditorBlockCallbacks( onFocus = { host.viewModel.onBlockFocused(block) }, @@ -73,6 +79,7 @@ fun LazyListScope.editorBlocks( host.onOpenSheet(type, block) }, onMove = { up -> host.viewModel.moveBlockOf(block, up) }, + onDuplicate = { host.viewModel.duplicateBlock(block) }, onDelete = { host.viewModel.removeBlock(block, BlockRemoveAction.REMOVE) }, ) } @@ -88,6 +95,9 @@ fun LazyListScope.editorBlocks( ) } + // Below the blocks, so the blocks keep their positions in the list. + if (afterBlocks != null) item(key = AFTER_BLOCKS_KEY) { afterBlocks() } + item(key = TAP_AREA_KEY) { Spacer( Modifier @@ -105,6 +115,7 @@ fun LazyListScope.editorBlocks( } private const val TAP_AREA_KEY = "editor-tap-area" +private const val AFTER_BLOCKS_KEY = "editor-after-blocks" /** What a block reports back to the editor: it got or lost focus, or asked for one of its sheets. */ private class EditorBlockCallbacks( @@ -112,6 +123,7 @@ private class EditorBlockCallbacks( val onFocusLost: () -> Unit, val onOpenSheet: (BottomSheetsOpenType) -> Unit, val onMove: (up: Boolean) -> Unit, + val onDuplicate: () -> Unit, val onDelete: () -> Unit, ) @@ -149,7 +161,11 @@ private fun EditorBlock( !focusState.hasFocus -> callbacks.onFocusLost() } } - .editorBlockFrame(), + .editorBlockFrame( + LocalNoteLineSpacing.current.frameInset.takeIf { + block is Block.TextBlock || block is Block.ListBlock || block is Block.QuoteBlock + }, + ), ) { BlockContent( block = block, @@ -167,7 +183,7 @@ private fun EditorBlock( } /** - * TalkBack's actions on a block's field: move it up or down, delete it. Without them a TalkBack + * TalkBack's actions on a block's field: move it up or down, duplicate it, delete it. Without them a TalkBack * user could only reach these through the toolbar's "Add block" row. */ @Composable @@ -178,10 +194,12 @@ private fun blockAccessibilityActions( ): Modifier { val moveUp = stringResource(R.string.block_move_up) val moveDown = stringResource(R.string.block_move_down) + val duplicate = stringResource(R.string.block_duplicate) val delete = stringResource(R.string.delete_block) val actions = buildList { if (!position.first) add(CustomAccessibilityAction(moveUp) { true.also { callbacks.onMove(true) } }) if (!position.last) add(CustomAccessibilityAction(moveDown) { true.also { callbacks.onMove(false) } }) + add(CustomAccessibilityAction(duplicate) { true.also { callbacks.onDuplicate() } }) if (!isOnlyBlock) add(CustomAccessibilityAction(delete) { true.also { callbacks.onDelete() } }) } return Modifier.semantics { customActions = actions } diff --git a/app/src/main/java/com/pasich/encly/presentation/editor/FindHighlights.kt b/app/src/main/java/com/pasich/encly/presentation/editor/FindHighlights.kt new file mode 100644 index 00000000..47138620 --- /dev/null +++ b/app/src/main/java/com/pasich/encly/presentation/editor/FindHighlights.kt @@ -0,0 +1,20 @@ +package com.pasich.encly.presentation.editor + +import androidx.compose.runtime.State +import com.pasich.encly.presentation.editor.state.FindMatch +import com.pasich.encly.presentation.editor.state.FindState +import com.pasich.encly.presentation.editor.state.fieldKey + +/** + * What the blocks tint for Find in this note. It reads the find state only while drawing, so a + * new match redraws the fields that show it and recomposes nothing; the text itself is never + * touched. + */ +class FindHighlights(private val state: State) { + /** The matches in the field of block [blockId] (or of its list item [itemId]). */ + fun matchesOf(blockId: String, itemId: String? = null): List = + state.value.byField[fieldKey(blockId, itemId)].orEmpty() + + /** The match "n of m" refers to, if any. */ + val current: FindMatch? get() = state.value.currentMatch +} diff --git a/app/src/main/java/com/pasich/encly/presentation/editor/blocks/BlockInput.kt b/app/src/main/java/com/pasich/encly/presentation/editor/blocks/BlockInput.kt index 7dc3ab6b..5253bb65 100644 --- a/app/src/main/java/com/pasich/encly/presentation/editor/blocks/BlockInput.kt +++ b/app/src/main/java/com/pasich/encly/presentation/editor/blocks/BlockInput.kt @@ -30,6 +30,10 @@ internal sealed interface BlockField { * the text before it. A paragraph also turns into a heading, list, quote or separator when its * mark is typed at its start, and into a link card when a web address alone is pasted into it. * + * A "/" typed into an empty paragraph opens the slash picker ([BlockActions.onSlashTyped]); it + * reacts to committed edits only: the picker is shown once the "/" is no longer under IME + * composition, and nothing is converted until the user picks a command. + * * The editor applies each rule at once, in one undo step. When it declines (simple editing), * the line break stays in the text. */ @@ -57,9 +61,13 @@ internal class BlockInput(private val field: BlockField, private val actions: () } } - field != BlockField.Paragraph || diff.removed.isNotEmpty() -> Unit + field != BlockField.Paragraph -> Unit - else -> onParagraphInsert(old, new, cursor, diff.inserted) + else -> { + if (diff.removed.isEmpty()) onParagraphInsert(old, new, cursor, diff.inserted) + // An open slash picker follows every edit, typed or deleted. + actions().onSlashTyped(new, started = old.isEmpty() && new == SLASH && cursor == 1) + } } } @@ -74,3 +82,6 @@ internal class BlockInput(private val field: BlockField, private val actions: () } } } + +/** The character that opens the slash picker in an empty paragraph. */ +private const val SLASH = "/" diff --git a/app/src/main/java/com/pasich/encly/presentation/editor/blocks/FieldHighlight.kt b/app/src/main/java/com/pasich/encly/presentation/editor/blocks/FieldHighlight.kt new file mode 100644 index 00000000..c28ec1c3 --- /dev/null +++ b/app/src/main/java/com/pasich/encly/presentation/editor/blocks/FieldHighlight.kt @@ -0,0 +1,66 @@ +package com.pasich.encly.presentation.editor.blocks + +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.TextLayoutResult +import androidx.compose.ui.unit.Density +import com.pasich.encly.presentation.editor.FindHighlights + +private const val MATCH_ALPHA = 0.18f +private const val CURRENT_MATCH_ALPHA = 0.4f + +/** + * Tints the Find matches of one text field behind its text. Pass [onTextLayout] to the field and + * add [modifier] to it; both are inert while Find has no match in this field. + */ +internal class FieldHighlight( + private val highlights: FindHighlights, + private val blockId: String, + private val itemId: String?, + private val match: Color, + private val currentMatch: Color, +) { + private var layout by mutableStateOf<(() -> TextLayoutResult?)?>(null) + + val onTextLayout: Density.(() -> TextLayoutResult?) -> Unit = { getResult -> + if (layout == null) layout = getResult + } + + val modifier: Modifier = Modifier.drawBehind { + val matches = highlights.matchesOf(blockId, itemId) + if (matches.isEmpty()) return@drawBehind + val result = layout?.invoke() ?: return@drawBehind + val length = result.layoutInput.text.length + val current = highlights.current + for (found in matches) { + if (found.start >= length) continue + val path = result.getPathForRange(found.start, minOf(found.end, length)) + drawPath(path, if (found == current) currentMatch else match) + } + } +} + +@Composable +internal fun rememberFieldHighlight( + highlights: FindHighlights, + blockId: String, + itemId: String? = null, +): FieldHighlight { + val primary = MaterialTheme.colorScheme.primary + return remember(highlights, blockId, itemId, primary) { + FieldHighlight( + highlights = highlights, + blockId = blockId, + itemId = itemId, + match = primary.copy(alpha = MATCH_ALPHA), + currentMatch = primary.copy(alpha = CURRENT_MATCH_ALPHA), + ) + } +} diff --git a/app/src/main/java/com/pasich/encly/presentation/editor/blocks/HBlock.kt b/app/src/main/java/com/pasich/encly/presentation/editor/blocks/HBlock.kt index 218f32d2..7a3c79f8 100644 --- a/app/src/main/java/com/pasich/encly/presentation/editor/blocks/HBlock.kt +++ b/app/src/main/java/com/pasich/encly/presentation/editor/blocks/HBlock.kt @@ -28,6 +28,7 @@ fun HBlock(block: Block.HBlock, blockActions: BlockActions, modifier: Modifier = val actions by rememberUpdatedState(blockActions) // Enter splits the heading; the text after the cursor goes on as a paragraph (BlockInput). val input = remember { BlockInput(BlockField.Styled) { actions } } + val highlight = rememberFieldHighlight(blockActions.highlights, block.id) BasicTextField( state = state, @@ -36,9 +37,11 @@ fun HBlock(block: Block.HBlock, blockActions: BlockActions, modifier: Modifier = cursorBrush = SolidColor(MaterialTheme.colorScheme.primary), textStyle = textStyle, keyboardOptions = WritingKeyboard, + onTextLayout = highlight.onTextLayout, modifier = modifier .fillMaxWidth() .padding(top = EnclyTheme.spacing.labelGap) + .then(highlight.modifier) .textBlockKeys(state, blockActions), decorator = { innerTextField -> Box(modifier = Modifier.fillMaxWidth()) { diff --git a/app/src/main/java/com/pasich/encly/presentation/editor/blocks/ListBlock.kt b/app/src/main/java/com/pasich/encly/presentation/editor/blocks/ListBlock.kt index 95172e6a..05f28754 100644 --- a/app/src/main/java/com/pasich/encly/presentation/editor/blocks/ListBlock.kt +++ b/app/src/main/java/com/pasich/encly/presentation/editor/blocks/ListBlock.kt @@ -39,16 +39,18 @@ import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp import com.pasich.encly.R import com.pasich.encly.domain.model.ItemListBlock import com.pasich.encly.dynamicBlocks.Block import com.pasich.encly.dynamicBlocks.BlockType import com.pasich.encly.presentation.designsystem.CheckboxSize import com.pasich.encly.presentation.designsystem.EnclyCheckbox +import com.pasich.encly.presentation.designsystem.lineHeight +import com.pasich.encly.presentation.designsystem.listItemGap import com.pasich.encly.presentation.editor.BlockActions import com.pasich.encly.presentation.editor.focus.KeyboardUtils import com.pasich.encly.presentation.editor.state.LineBreak +import com.pasich.encly.presentation.screen.editnote.LocalNoteLineSpacing import com.pasich.encly.presentation.screen.editnote.rememberFontStyles import com.pasich.encly.ui.theme.EnclyTheme @@ -130,7 +132,9 @@ fun ListBlock( ) Column( modifier = modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(if (checklist) 0.dp else EnclyTheme.spacing.xxs), + verticalArrangement = Arrangement.spacedBy( + LocalNoteLineSpacing.current.listItemGap(checklist, EnclyTheme.spacing.xxs), + ), ) { items.forEachIndexed { itemIndex, item -> key(item.id) { @@ -151,7 +155,7 @@ fun ListBlock( ListItemRow( item = item, marker = marker.forItem(itemIndex + 1), - state = state, + field = ItemField(state, rememberFieldHighlight(blockActions.highlights, block.id, item.id)), editor = editor, isLocked = isLocked, fieldModifier = fieldModifier.focusRequester(requester), @@ -161,6 +165,9 @@ fun ListBlock( } } +/** A list item's text field state and what Find tints in it. */ +private class ItemField(val state: TextFieldState, val highlight: FieldHighlight) + /** * How a list marks its items: checkboxes, bullets or numbers in a hanging-indent column of * [width]; [number] is the item's own. @@ -173,12 +180,13 @@ private data class ListMarker(val type: BlockType, val width: Dp, val number: In private fun ListItemRow( item: ItemListBlock, marker: ListMarker, - state: TextFieldState, + field: ItemField, editor: ListEditor, isLocked: Boolean, modifier: Modifier = Modifier, fieldModifier: Modifier = Modifier, ) { + val state = field.state val checklist = marker.type == BlockType.LIST_CHECK val placeholder = stringResource(R.string.list_item_placeholder) Row( @@ -199,7 +207,7 @@ private fun ListItemRow( else -> Marker("•", marker.width, listTextStyle(), Modifier.clearAndSetSemantics { }) } ListItemField( - state = state, + field = field, input = editor.inputFor(item.id), checked = checklist && item.isCheck, placeholder = placeholder, @@ -279,10 +287,12 @@ private class ListEditor( @Composable private fun listTextStyle(): TextStyle { val fontStyles = rememberFontStyles() + val spacing = LocalNoteLineSpacing.current return MaterialTheme.typography.bodyLarge.copy( color = MaterialTheme.colorScheme.onSurface, fontFamily = fontStyles.families.body, fontSize = fontStyles.sizes.list, + lineHeight = spacing.lineHeight(fontStyles.sizes.list, MaterialTheme.typography.bodyLarge.lineHeight), ) } @@ -322,13 +332,15 @@ private fun CheckMark(checked: Boolean, label: String, enabled: Boolean, onToggl @Composable private fun ListItemField( - state: TextFieldState, + field: ItemField, input: BlockInput, checked: Boolean, placeholder: String, isLocked: Boolean, modifier: Modifier = Modifier, ) { + val state = field.state + val highlight = field.highlight val base = listTextStyle() // Checked items stay editable; they are only muted and struck through. val style = if (checked) { @@ -343,7 +355,8 @@ private fun ListItemField( textStyle = style, cursorBrush = SolidColor(MaterialTheme.colorScheme.primary), keyboardOptions = WritingKeyboard, - modifier = modifier, + onTextLayout = highlight.onTextLayout, + modifier = modifier.then(highlight.modifier), decorator = { innerTextField -> Box(modifier = Modifier.fillMaxWidth()) { if (state.text.isEmpty() && !isLocked) { diff --git a/app/src/main/java/com/pasich/encly/presentation/editor/blocks/QuoteBlock.kt b/app/src/main/java/com/pasich/encly/presentation/editor/blocks/QuoteBlock.kt index 12e4d3f8..900eb166 100644 --- a/app/src/main/java/com/pasich/encly/presentation/editor/blocks/QuoteBlock.kt +++ b/app/src/main/java/com/pasich/encly/presentation/editor/blocks/QuoteBlock.kt @@ -26,7 +26,9 @@ import androidx.compose.ui.text.TextStyle import com.pasich.encly.R import com.pasich.encly.dynamicBlocks.Block import com.pasich.encly.presentation.designsystem.EnclyIcons +import com.pasich.encly.presentation.designsystem.lineHeight import com.pasich.encly.presentation.editor.BlockActions +import com.pasich.encly.presentation.screen.editnote.LocalNoteLineSpacing import com.pasich.encly.presentation.screen.editnote.rememberFontStyles import com.pasich.encly.ui.theme.EnclyTheme @@ -51,9 +53,14 @@ fun QuoteBlock( val actions by rememberUpdatedState(blockActions) // Enter ends the quote: the text after the cursor goes on as a paragraph (BlockInput). val input = remember { BlockInput(BlockField.Styled) { actions } } + val highlight = rememberFieldHighlight(blockActions.highlights, block.id) val quoteStyle = EnclyTheme.typography.quote.copy( color = MaterialTheme.colorScheme.onSurface, fontSize = fontStyles.sizes.quote, + lineHeight = LocalNoteLineSpacing.current.lineHeight( + fontSize = fontStyles.sizes.quote, + normal = EnclyTheme.typography.quote.lineHeight, + ), ) // A tonal card between an opening and a closing quote mark in `primary`, 8 dp below the block @@ -79,9 +86,11 @@ fun QuoteBlock( cursorBrush = SolidColor(MaterialTheme.colorScheme.primary), keyboardOptions = WritingKeyboard, textStyle = quoteStyle, + onTextLayout = highlight.onTextLayout, modifier = fieldModifier .focusRequester(focusRequester) .fillMaxWidth() + .then(highlight.modifier) .textBlockKeys(state, blockActions), decorator = { innerTextField -> QuoteDecoration(state.text.isEmpty(), quoteStyle, innerTextField) diff --git a/app/src/main/java/com/pasich/encly/presentation/editor/blocks/SlashMenuPopup.kt b/app/src/main/java/com/pasich/encly/presentation/editor/blocks/SlashMenuPopup.kt new file mode 100644 index 00000000..de2e191e --- /dev/null +++ b/app/src/main/java/com/pasich/encly/presentation/editor/blocks/SlashMenuPopup.kt @@ -0,0 +1,145 @@ +package com.pasich.encly.presentation.editor.blocks + +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalResources +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntRect +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.window.Popup +import androidx.compose.ui.window.PopupPositionProvider +import androidx.compose.ui.window.PopupProperties +import com.pasich.encly.R +import com.pasich.encly.presentation.designsystem.CommandMenuItem +import com.pasich.encly.presentation.designsystem.EnclyCommandMenu +import com.pasich.encly.presentation.designsystem.EnclyIcons +import com.pasich.encly.presentation.designsystem.EnclyToolbarReserve +import com.pasich.encly.presentation.editor.SlashActions +import com.pasich.encly.presentation.editor.state.SlashCommand +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map + +/** + * The slash picker of a paragraph: a small list of block kinds under the field, filtered by what + * was typed after the "/". It never takes focus, so the keyboard stays on the paragraph and the + * user goes on typing; Enter and the arrow keys are handled in the paragraph's key handling and + * in [BlockInput]. It stays hidden until the "/" is committed text, so a character still under IME + * composition never opens it. + */ +@Composable +internal fun SlashMenuPopup(field: TextFieldState, slash: SlashActions, modifier: Modifier = Modifier) { + // Only whether this paragraph owns the menu is observed here, so the other paragraphs do not + // recompose each time the open menu changes (a typed letter, a moved highlight). A menu opens + // only after the "/" is typed, so starting from "not the owner" never hides an open one. + val owns by remember(slash.menu, slash.blockId) { + slash.menu.map { it?.blockId == slash.blockId }.distinctUntilChanged() + }.collectAsState(initial = false) + if (owns) OpenSlashMenu(field, slash, modifier) +} + +@Composable +private fun OpenSlashMenu(field: TextFieldState, slash: SlashActions, modifier: Modifier = Modifier) { + val all by slash.menu.collectAsState() + val menu = all?.takeIf { it.blockId == slash.blockId } + val labels = slashLabels() + val currentSlash by rememberUpdatedState(slash) + + LaunchedEffect(menu != null, labels) { + if (menu != null) currentSlash.setLabels(labels) + } + val awaitingCommit = menu != null && !menu.committed + LaunchedEffect(awaitingCommit) { + if (awaitingCommit) { + snapshotFlow { field.composition == null }.first { it } + currentSlash.commit() + } + } + if (menu == null || !menu.isShown) return + + val density = LocalDensity.current + val reserve = with(density) { EnclyToolbarReserve.roundToPx() } + val bottomInset = WindowInsets.safeDrawing.getBottom(density) + val position = remember(reserve, bottomInset) { UnderOrOverField(bottomLimit = { it - bottomInset - reserve }) } + Popup( + popupPositionProvider = position, + onDismissRequest = { currentSlash.dismiss() }, + // Not focusable: the keyboard and the caret stay in the paragraph. + properties = PopupProperties(focusable = false), + ) { + EnclyCommandMenu( + items = menu.matches.map { CommandMenuItem(it.icon(), labels.getValue(it)) }, + highlighted = menu.highlighted, + title = stringResource(R.string.slash_menu_title), + onSelect = { currentSlash.select(menu.matches[it]) }, + modifier = modifier, + ) + } +} + +/** The labels built once for the whole editor (see [rememberSlashLabels]); null where nothing provides them. */ +internal val LocalSlashLabels = compositionLocalOf?> { null } + +/** The localized label of every command, for the list and for matching the query. */ +@Composable +private fun slashLabels(): Map = LocalSlashLabels.current ?: rememberSlashLabels() + +/** The localized label of every command, built once per locale: provide it as [LocalSlashLabels] above the editor. */ +@Composable +internal fun rememberSlashLabels(): Map { + val resources = LocalResources.current + val configuration = LocalConfiguration.current + return remember(resources, configuration) { + SlashCommand.entries.associateWith { command -> + command.headingLevel?.let { resources.getString(command.label, it) } ?: resources.getString(command.label) + } + } +} + +private fun SlashCommand.icon(): ImageVector = when (this) { + SlashCommand.TEXT -> EnclyIcons.Type + SlashCommand.H1 -> EnclyIcons.Heading1 + SlashCommand.H2 -> EnclyIcons.Heading2 + SlashCommand.H3 -> EnclyIcons.Heading3 + SlashCommand.H4 -> EnclyIcons.Heading4 + SlashCommand.CHECKLIST -> EnclyIcons.Checklist + SlashCommand.BULLETS -> EnclyIcons.ListBullet + SlashCommand.NUMBERED -> EnclyIcons.ListNumbered + SlashCommand.QUOTE -> EnclyIcons.Quote + SlashCommand.LINK -> EnclyIcons.Link + SlashCommand.DIVIDER -> EnclyIcons.Separator +} + +/** + * Under the field when the list fits above [bottomLimit] (the window's height less the keyboard and + * the editor toolbar), else over it; always inside the window's width. + */ +private class UnderOrOverField(private val bottomLimit: (windowHeight: Int) -> Int) : PopupPositionProvider { + override fun calculatePosition( + anchorBounds: IntRect, + windowSize: IntSize, + layoutDirection: LayoutDirection, + popupContentSize: IntSize, + ): IntOffset { + val x = anchorBounds.left.coerceIn(0, (windowSize.width - popupContentSize.width).coerceAtLeast(0)) + val below = anchorBounds.bottom + val fitsBelow = below + popupContentSize.height <= bottomLimit(windowSize.height) + val y = if (fitsBelow) below else (anchorBounds.top - popupContentSize.height).coerceAtLeast(0) + return IntOffset(x, y) + } +} diff --git a/app/src/main/java/com/pasich/encly/presentation/editor/blocks/TextBlock.kt b/app/src/main/java/com/pasich/encly/presentation/editor/blocks/TextBlock.kt index 0161af24..bcb9ec91 100644 --- a/app/src/main/java/com/pasich/encly/presentation/editor/blocks/TextBlock.kt +++ b/app/src/main/java/com/pasich/encly/presentation/editor/blocks/TextBlock.kt @@ -16,7 +16,9 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.input.KeyboardType import com.pasich.encly.dynamicBlocks.Block +import com.pasich.encly.presentation.designsystem.lineHeight import com.pasich.encly.presentation.editor.BlockActions +import com.pasich.encly.presentation.screen.editnote.LocalNoteLineSpacing import com.pasich.encly.presentation.screen.editnote.rememberFontStyles /** @@ -39,14 +41,17 @@ fun TextBlock( showPlaceholder: Boolean = true, ) { val fontStyles = rememberFontStyles() + val spacing = LocalNoteLineSpacing.current val state = rememberBlockTextFieldState(block.text, blockActions) val style = MaterialTheme.typography.bodyLarge.copy( color = MaterialTheme.colorScheme.onSurface, fontSize = fontStyles.sizes.textBlock, fontFamily = fontStyles.families.body, + lineHeight = spacing.lineHeight(fontStyles.sizes.textBlock, MaterialTheme.typography.bodyLarge.lineHeight), ) val actions by rememberUpdatedState(blockActions) val input = remember { BlockInput(BlockField.Paragraph) { actions } } + val highlight = rememberFieldHighlight(blockActions.highlights, block.id) BasicTextField( state = state, @@ -56,11 +61,14 @@ fun TextBlock( textStyle = style, cursorBrush = SolidColor(MaterialTheme.colorScheme.primary), keyboardOptions = WritingKeyboard, + onTextLayout = highlight.onTextLayout, modifier = modifier .fillMaxWidth() + .then(highlight.modifier) .textBlockKeys(state, blockActions), decorator = { innerTextField -> Box(modifier = Modifier.fillMaxWidth()) { + SlashMenuPopup(field = state, slash = blockActions.slash) if (showPlaceholder && state.text.isEmpty()) { Text( text = stringResource(block.placeholder), diff --git a/app/src/main/java/com/pasich/encly/presentation/editor/blocks/TextBlockKeys.kt b/app/src/main/java/com/pasich/encly/presentation/editor/blocks/TextBlockKeys.kt index efec1145..8c3e8755 100644 --- a/app/src/main/java/com/pasich/encly/presentation/editor/blocks/TextBlockKeys.kt +++ b/app/src/main/java/com/pasich/encly/presentation/editor/blocks/TextBlockKeys.kt @@ -2,8 +2,14 @@ package com.pasich.encly.presentation.editor.blocks import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.ui.Modifier +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEvent +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.key import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.input.key.type import com.pasich.encly.presentation.editor.BlockActions +import com.pasich.encly.presentation.editor.SlashActions import com.pasich.encly.presentation.editor.focus.KeyboardUtils import com.pasich.encly.presentation.editor.state.BlockRemoveAction @@ -17,7 +23,7 @@ import com.pasich.encly.presentation.editor.state.BlockRemoveAction internal fun Modifier.textBlockKeys(state: TextFieldState, blockActions: BlockActions): Modifier = onPreviewKeyEvent { event -> val selection = state.selection - KeyboardUtils.handleKeyEvent( + slashKey(event, blockActions.slash) || KeyboardUtils.handleKeyEvent( event = event, text = state.text, // A selection is not a cursor at either end: the field moves within it. @@ -31,3 +37,19 @@ internal fun Modifier.textBlockKeys(state: TextFieldState, blockActions: BlockAc }, ) } + +/** + * Keys for a slash picker that is showing on this paragraph: Down and Up move its highlight, + * Escape closes it (the "/" stays as typed). Enter is the field's own line break, which the editor + * turns into picking the highlighted command. Everything else, and every key while no picker + * shows, is left to the field. + */ +private fun slashKey(event: KeyEvent, slash: SlashActions): Boolean { + val shown = slash.menu.value?.takeIf { it.blockId == slash.blockId && it.isShown } != null + return shown && event.type == KeyEventType.KeyDown && when (event.key) { + Key.DirectionDown -> true.also { slash.moveHighlight(1) } + Key.DirectionUp -> true.also { slash.moveHighlight(-1) } + Key.Escape -> true.also { slash.dismiss() } + else -> false + } +} diff --git a/app/src/main/java/com/pasich/encly/presentation/editor/state/BlockDuplicate.kt b/app/src/main/java/com/pasich/encly/presentation/editor/state/BlockDuplicate.kt new file mode 100644 index 00000000..4f5a0314 --- /dev/null +++ b/app/src/main/java/com/pasich/encly/presentation/editor/state/BlockDuplicate.kt @@ -0,0 +1,42 @@ +package com.pasich.encly.presentation.editor.state + +import com.pasich.encly.dynamicBlocks.Block +import com.pasich.encly.dynamicBlocks.newBlockId +import kotlinx.coroutines.flow.MutableStateFlow + +/** Duplicates the block the user works on (see [duplicateBlockOf]). */ +fun BlockEditorState.duplicateInteracted() { + blocks.getOrNull(selection.interactedIndex)?.let { duplicateBlockOf(it.id) } +} + +/** + * Inserts a copy of block [blockId] right after it, as one undo step, and moves the user to the + * copy (the cursor at its end). The copy shares nothing with the original: its content lives in + * new state holders and its block and list item ids are new, so editing one never changes the + * other. Returns false when the block is no longer there. + */ +fun BlockEditorState.duplicateBlockOf(blockId: String): Boolean { + val index = indexOf(blockId) + val copy = blocks.getOrNull(index)?.duplicate() ?: return false + batch { addBlock(index + 1, copy) } + if (copy.canTakeFocus()) selection.focusAt(index + 1, cursorToEnd = true) else selection.onInteraction(copy.id) + return true +} + +/** A copy of [this] block that shares no state or identity with it. */ +internal fun Block.duplicate(): Block = when (this) { + is Block.TextBlock -> copy(text = MutableStateFlow(text.value), id = newBlockId()) + + is Block.HBlock -> copy(text = MutableStateFlow(text.value), id = newBlockId()) + + is Block.QuoteBlock -> copy(text = MutableStateFlow(text.value), id = newBlockId()) + + is Block.LinkBlock -> copy(block = MutableStateFlow(block.value.copy()), id = newBlockId()) + + is Block.SeparatorBlock -> copy(id = newBlockId()) + + is Block.ListBlock -> copy( + items = MutableStateFlow(items.value.map { it.copy(id = newBlockId()) }), + id = newBlockId(), + ) +} diff --git a/app/src/main/java/com/pasich/encly/presentation/editor/state/BlockEditing.kt b/app/src/main/java/com/pasich/encly/presentation/editor/state/BlockEditing.kt index 0543e707..2141d741 100644 --- a/app/src/main/java/com/pasich/encly/presentation/editor/state/BlockEditing.kt +++ b/app/src/main/java/com/pasich/encly/presentation/editor/state/BlockEditing.kt @@ -57,38 +57,6 @@ private fun BlockEditorState.enter(index: Int, block: TextualBlock, head: String } } -private fun BlockEditorState.pasteLines(index: Int, block: TextualBlock, lineBreak: LineBreak): String { - val (head, lines, tail) = lineBreak - // Pasted into an empty paragraph: every line is read, the first one too. - if (block is Block.TextBlock && head.isEmpty() && tail.isEmpty()) { - val pasted = withTrailingParagraph(pastedBlocks(lines)) - batch { - replaceBlock(index, pasted.first()) - pasted.drop(1).forEachIndexed { i, added -> addBlock(index + 1 + i, added) } - } - selection.focusAt(index + pasted.lastIndex, cursorToEnd = true) - return "" - } - val kept = head + lines.first() - val rest = lines.drop(1) - // The last line joins the text after the cursor, which stays a paragraph. - val carried = Block.TextBlock(MutableStateFlow(rest.last() + tail)).takeIf { tail.isNotEmpty() } - val added = when (carried) { - null -> withTrailingParagraph(pastedBlocks(rest)) - else -> pastedBlocks(rest.dropLast(1)) + carried - } - batch { - setValue(blocks[index].id, block.text, kept) - added.forEachIndexed { i, new -> addBlock(index + 1 + i, new) } - } - val last = index + added.size - when (carried) { - null -> selection.focusAt(last, cursorToEnd = true) - else -> selection.focusAt(last, caret = rest.last().length) - } - return kept -} - /** * Backspace with the cursor at the start of block [blockId]. A heading or quote first turns * into a paragraph; a paragraph joins the block above (a heading, quote, paragraph or the last @@ -190,7 +158,7 @@ fun BlockEditorState.pasteLink(blockId: String, pasted: String, link: LinkDataBl } /** Puts [block] at [index] and focuses the paragraph after it, adding one unless there is one. */ -private fun BlockEditorState.replaceWithBlockAndParagraph(index: Int, block: Block) { +internal fun BlockEditorState.replaceWithBlockAndParagraph(index: Int, block: Block) { batch { replaceBlock(index, block) if (blocks.getOrNull(index + 1) !is Block.TextBlock) addBlock(index + 1, Block.TextBlock()) diff --git a/app/src/main/java/com/pasich/encly/presentation/editor/state/BlockEditorState.kt b/app/src/main/java/com/pasich/encly/presentation/editor/state/BlockEditorState.kt index 5babbcb2..3b587f58 100644 --- a/app/src/main/java/com/pasich/encly/presentation/editor/state/BlockEditorState.kt +++ b/app/src/main/java/com/pasich/encly/presentation/editor/state/BlockEditorState.kt @@ -34,6 +34,12 @@ class BlockEditorState(clock: () -> Long = System::currentTimeMillis) { val selection = BlockSelection(_blocks) + /** The slash picker: closes at any structural change, so it never outlives its paragraph. */ + val slash = SlashPicker() + + /** Find in this note: transient, read-only, forgotten when the note is loaded or the vault locks. */ + val find = NoteFind() + private val _canUndo = MutableStateFlow(false) val canUndo: StateFlow = _canUndo.asStateFlow() @@ -53,6 +59,8 @@ class BlockEditorState(clock: () -> Long = System::currentTimeMillis) { * over: undo never reaches back into what was shown before (nor keeps it in memory). */ fun load(loaded: List) { + slash.dismiss() + find.close() _blocks.clear() _blocks.addAll(loaded) history.clear() @@ -65,6 +73,7 @@ class BlockEditorState(clock: () -> Long = System::currentTimeMillis) { * never leave the note without blocks. */ fun addBlock(block: Block) { + slash.dismiss() val anchor = selection.workingIndex.coerceIn(-1, _blocks.lastIndex) val replacesEmptyFirst = block !is Block.TextBlock && _blocks.isEmptyFirstTextBlock(anchor) @@ -87,6 +96,7 @@ class BlockEditorState(clock: () -> Long = System::currentTimeMillis) { } if (index == -1 || !allowed) return + slash.dismiss() history.removeBlock(index) afterChange() if (refocus) selection.focusAt(_blocks.previousFocusableIndex(index), cursorToEnd = true) @@ -98,6 +108,7 @@ class BlockEditorState(clock: () -> Long = System::currentTimeMillis) { */ fun replaceBlock(index: Int, newBlock: Block): Boolean { if (index !in _blocks.indices) return false + slash.dismiss() val caret = selection.caretOf(_blocks[index].id)?.takeIf { newBlock is TextualBlock } history.replaceBlock(index, newBlock) afterChange() @@ -108,6 +119,7 @@ class BlockEditorState(clock: () -> Long = System::currentTimeMillis) { /** Moves the block at [fromIndex] to [toIndex]; it keeps focus. */ fun moveBlock(fromIndex: Int, toIndex: Int) { if (fromIndex !in _blocks.indices || toIndex !in _blocks.indices) return + slash.dismiss() history.moveBlock(fromIndex, toIndex) afterChange() selection.focusAt(toIndex) @@ -130,15 +142,18 @@ class BlockEditorState(clock: () -> Long = System::currentTimeMillis) { /** Makes the operations of [build] as one undo step (see [BlockOperations.batch]). */ fun batch(build: BlockOperations.Batch.() -> Unit) { + slash.dismiss() history.batch(build) afterChange() } fun undo() { + slash.dismiss() if (history.undo()) afterChange() } fun redo() { + slash.dismiss() if (history.redo()) afterChange() } diff --git a/app/src/main/java/com/pasich/encly/presentation/editor/state/NoteFind.kt b/app/src/main/java/com/pasich/encly/presentation/editor/state/NoteFind.kt new file mode 100644 index 00000000..007502a4 --- /dev/null +++ b/app/src/main/java/com/pasich/encly/presentation/editor/state/NoteFind.kt @@ -0,0 +1,115 @@ +package com.pasich.encly.presentation.editor.state + +import com.pasich.encly.dynamicBlocks.Block +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update + +// Find in this note: a read-only search of the open note's text. The query and the matches live +// only in memory, in the editor ViewModel; they are not saved with the screen's state and are +// dropped when the vault locks. + +/** + * One match in the text of paragraph, heading or quote [blockId] (or of its list item [itemId]), + * at the half-open range [start], [end] of that field's text. + */ +data class FindMatch(val blockId: String, val itemId: String?, val start: Int, val end: Int) { + /** The field the match is in, as [FindState.byField] keys it. */ + val field: String get() = fieldKey(blockId, itemId) +} + +/** Key of a block's text field, or of its list item's. */ +fun fieldKey(blockId: String, itemId: String?): String = if (itemId == null) blockId else "$blockId/$itemId" + +/** The find bar: whether it is open, what it searches for, and where it found it ([current]: -1 for none). */ +data class FindState( + val isOpen: Boolean = false, + val query: String = "", + val matches: List = emptyList(), + val current: Int = -1, +) { + /** The matches of each field, for drawing. */ + val byField: Map> by lazy(LazyThreadSafetyMode.NONE) { matches.groupBy { it.field } } + + val currentMatch: FindMatch? get() = matches.getOrNull(current) +} + +/** + * Every place [query] occurs in the text of [blocks], in reading order, ignoring case. Only + * the blocks' own text is searched (paragraphs, headings, quotes and list items); the blocks are + * never touched. A blank query finds nothing. + */ +fun findMatches(blocks: List, query: String): List { + if (query.isBlank()) return emptyList() + val found = mutableListOf() + for (block in blocks) { + when (block) { + is Block.TextBlock -> found.addMatches(block.id, null, block.text.value, query) + is Block.HBlock -> found.addMatches(block.id, null, block.text.value, query) + is Block.QuoteBlock -> found.addMatches(block.id, null, block.text.value, query) + is Block.ListBlock -> block.items.value.forEach { found.addMatches(block.id, it.id, it.value, query) } + is Block.LinkBlock, is Block.SeparatorBlock -> Unit + } + } + return found +} + +private fun MutableList.addMatches(blockId: String, itemId: String?, text: String, query: String) { + var from = text.indexOf(query, ignoreCase = true) + while (from >= 0) { + add(FindMatch(blockId, itemId, from, from + query.length)) + from = text.indexOf(query, from + query.length, ignoreCase = true) + } +} + +/** The find bar's state and its movement through the matches. */ +class NoteFind { + private val _state = MutableStateFlow(FindState()) + val state: StateFlow = _state.asStateFlow() + + val isOpen: Boolean get() = _state.value.isOpen + + fun open() { + _state.update { it.copy(isOpen = true) } + } + + /** Closes the bar and forgets the query and every match. */ + fun close() { + _state.value = FindState() + } + + /** Searches [blocks] for [query]; the first match becomes the current one. */ + fun search(query: String, blocks: List) { + val matches = findMatches(blocks, query) + _state.update { it.copy(query = query, matches = matches, current = if (matches.isEmpty()) -1 else 0) } + } + + /** + * The text changed: searches [blocks] again for the same query. The current match stays at + * its place in the order, or the nearest one left. + */ + fun refresh(blocks: List) { + _state.update { now -> + if (!now.isOpen || now.query.isBlank()) return@update now + val matches = findMatches(blocks, now.query) + now.copy( + matches = matches, + current = if (matches.isEmpty()) -1 else now.current.coerceIn(0, matches.lastIndex), + ) + } + } + + /** The next match, wrapping round after the last. */ + fun next() = step(1) + + /** The match before, wrapping round before the first. */ + fun previous() = step(-1) + + private fun step(delta: Int) { + _state.update { now -> + val size = now.matches.size + if (size == 0) now else now.copy(current = ((now.current + delta) % size + size) % size) + } + } +} diff --git a/app/src/main/java/com/pasich/encly/presentation/editor/state/NoteTemplates.kt b/app/src/main/java/com/pasich/encly/presentation/editor/state/NoteTemplates.kt new file mode 100644 index 00000000..cd003950 --- /dev/null +++ b/app/src/main/java/com/pasich/encly/presentation/editor/state/NoteTemplates.kt @@ -0,0 +1,122 @@ +package com.pasich.encly.presentation.editor.state + +import androidx.annotation.StringRes +import com.pasich.encly.R +import com.pasich.encly.dynamicBlocks.Block +import com.pasich.encly.dynamicBlocks.BlockType +import com.pasich.encly.dynamicBlocks.factory.BlockFactory +import kotlinx.coroutines.flow.MutableStateFlow + +// Simple note templates: a starting outline of ordinary blocks (headings, paragraphs, lists) +// for a new, still empty note. They are code and string resources, so they work offline and +// store nothing of their own; a template is just the blocks it makes. + +/** One piece of a template. */ +sealed interface TemplatePart { + /** A heading of [type] (H1-H4) reading the string resource [text]. */ + data class Heading(val type: BlockType, @param:StringRes val text: Int) : TemplatePart + + /** An empty paragraph to write in. */ + data object Paragraph : TemplatePart + + /** An empty list of [type] (bullets, numbers or checklist). */ + data class ListOf(val type: BlockType) : TemplatePart +} + +/** The templates a new note can start from, in the order they are offered. */ +enum class NoteTemplate(@param:StringRes val label: Int, val parts: List) { + /** What a new note is today: nothing. */ + BLANK(R.string.template_blank, emptyList()), + + MEETING( + R.string.template_meeting, + listOf( + heading(R.string.template_attendees), + TemplatePart.ListOf(BlockType.LIST_BULLET), + heading(R.string.template_agenda), + TemplatePart.ListOf(BlockType.LIST_NUMBER), + heading(R.string.template_notes), + TemplatePart.Paragraph, + heading(R.string.template_action_items), + TemplatePart.ListOf(BlockType.LIST_CHECK), + ), + ), + + JOURNAL( + R.string.template_journal, + listOf( + heading(R.string.template_journal_today), + TemplatePart.Paragraph, + heading(R.string.template_journal_gratitude), + TemplatePart.ListOf(BlockType.LIST_BULLET), + heading(R.string.template_journal_tomorrow), + TemplatePart.Paragraph, + ), + ), + + PROJECT( + R.string.template_project, + listOf( + heading(R.string.template_project_goal), + TemplatePart.Paragraph, + heading(R.string.template_project_tasks), + TemplatePart.ListOf(BlockType.LIST_CHECK), + heading(R.string.template_project_decisions), + TemplatePart.ListOf(BlockType.LIST_BULLET), + heading(R.string.template_project_questions), + TemplatePart.ListOf(BlockType.LIST_BULLET), + ), + ), + + CHECKLIST( + R.string.block_checklist, + listOf( + heading(R.string.template_checklist_todo), + TemplatePart.ListOf(BlockType.LIST_CHECK), + ), + ), +} + +private fun heading(@StringRes text: Int) = TemplatePart.Heading(BlockType.H2, text) + +/** + * The blocks of [this] template, headings read by [resolve] (the UI's string lookup, so the + * text follows the in-app language). Fresh blocks each time; empty for [NoteTemplate.BLANK]. + */ +fun NoteTemplate.blocks(resolve: (Int) -> String): List = parts.map { part -> + when (part) { + is TemplatePart.Heading -> + Block.HBlock(text = MutableStateFlow(resolve(part.text)), blockType = part.type) + + TemplatePart.Paragraph -> Block.TextBlock() + + is TemplatePart.ListOf -> BlockFactory.createBlock(part.type) + } +} + +/** Whether the note is exactly what a new note starts as: one empty paragraph. */ +fun BlockEditorState.isBlankNote(): Boolean = + (blocks.singleOrNull() as? Block.TextBlock)?.text?.value?.isEmpty() == true + +/** + * Starts the blank note from [template]: its blocks take the place of the empty paragraph, as + * one undo step (undo gives the blank note back), and the first empty field after the first + * heading takes focus. Does nothing, and returns false, unless the note is still blank. + */ +fun BlockEditorState.applyTemplate(template: List): Boolean { + if (template.isEmpty() || !isBlankNote()) return false + batch { + replaceBlock(0, template.first()) + template.drop(1).forEachIndexed { i, block -> addBlock(1 + i, block) } + } + val target = template.indexOfFirst { it.isEmptyField() }.takeIf { it >= 0 } ?: template.lastIndex + val item = (template[target] as? Block.ListBlock)?.items?.value?.firstOrNull() + selection.focusAt(target, caret = 0, itemId = item?.id) + return true +} + +private fun Block.isEmptyField(): Boolean = when (this) { + is Block.TextBlock -> text.value.isEmpty() + is Block.ListBlock -> items.value.all { it.value.isEmpty() } + else -> false +} diff --git a/app/src/main/java/com/pasich/encly/presentation/editor/state/PasteEditing.kt b/app/src/main/java/com/pasich/encly/presentation/editor/state/PasteEditing.kt new file mode 100644 index 00000000..d22c94c6 --- /dev/null +++ b/app/src/main/java/com/pasich/encly/presentation/editor/state/PasteEditing.kt @@ -0,0 +1,106 @@ +package com.pasich.encly.presentation.editor.state + +import com.pasich.encly.dynamicBlocks.Block +import com.pasich.encly.dynamicBlocks.TextualBlock +import kotlinx.coroutines.flow.MutableStateFlow + +/** + * Pasted lines in a paragraph, heading or quote (Smart Paste). The text is read as Markdown + * ([pastedBlocks]): a paste that is one paragraph goes into the block at the cursor, line breaks + * and all; otherwise its first paragraph joins the text before the cursor, the other blocks + * follow, and the text after the cursor goes on at the end of the last paragraph, as the kind of + * block it came from (text after the cursor in a heading stays a heading of that level, in a + * quote a quote). When the cursor is after text, the first pasted line joins it as plain text + * and is never read as a mark; so is a single line with a trailing line break, next to any text. + * Whitespace-only lines at either end of the paste are dropped; everything else is kept. One + * undo step. + */ +internal fun BlockEditorState.pasteLines(index: Int, block: TextualBlock, lineBreak: LineBreak): String { + val pasted = trimBlankLines(lineBreak.lines) + // One content line is only a line break at the end of the copied text: no marks to read, + // unless it lands in an empty block, where a lone address or a mark is what the user meant. + val inText = lineBreak.head.isNotEmpty() || lineBreak.tail.isNotEmpty() + val mapped = if (pasted.size == 1 && inText) emptyList() else pastedBlocks(pasted, lineBreak.head.isNotEmpty()) + return when { + pasted.isEmpty() -> { + // Nothing but blank lines: no content to paste. + selection.focusAt(index, caret = lineBreak.head.length) + lineBreak.head + lineBreak.tail + } + + // One line (its line break is just the end of the copied text), or text the mapper left + // nothing of: pasted as typed. + mapped.isEmpty() || mapped.singleOrNull() is Block.TextBlock -> pasteParagraph(index, block, lineBreak, pasted) + + else -> pasteBlocks(index, block, lineBreak, pasted, mapped) + } +} + +/** A paste that is one paragraph: it goes into the block at the cursor, line breaks and all. */ +private fun BlockEditorState.pasteParagraph( + index: Int, + block: TextualBlock, + lineBreak: LineBreak, + pasted: List, +): String { + val text = pasted.joinToString("\n").let { if (block is Block.HBlock) it.replace('\n', ' ') else it } + val kept = lineBreak.head + text + lineBreak.tail + batch { setValue(blocks[index].id, block.text, kept) } + selection.focusAt(index, caret = lineBreak.head.length + text.length) + return kept +} + +/** A paste of several paragraphs or marks: see [pasteLines]. */ +private fun BlockEditorState.pasteBlocks( + index: Int, + block: TextualBlock, + lineBreak: LineBreak, + pasted: List, + mapped: List, +): String { + val (head, _, tail) = lineBreak + val first = mapped.first() as? Block.TextBlock + val lead = when (first) { + null -> head + else -> head + pasted.first().takeWhile { it.isWhitespace() } + first.text.value.flattenFor(block) + } + var rest = if (first == null) mapped else mapped.drop(1) + var caret = 0 + if (tail.isEmpty()) { + rest = withTrailingParagraph(rest) + } else { + // The pasted text goes on into what followed the cursor. + val last = rest.last() as? Block.TextBlock + if (last == null) { + rest = rest + block.sameKind(tail) + } else { + val joined = last.text.value + pasted.last().takeLastWhile { it.isWhitespace() } + caret = joined.length + rest = rest.dropLast(1) + block.sameKind(joined + tail) + } + } + // Nothing before the cursor and nothing to join: the pasted blocks take the block's place. + val replaceCurrent = first == null && head.isEmpty() + batch { + if (replaceCurrent) { + replaceBlock(index, rest.first()) + rest.drop(1).forEachIndexed { i, added -> addBlock(index + 1 + i, added) } + } else { + setValue(blocks[index].id, block.text, lead) + rest.forEachIndexed { i, added -> addBlock(index + 1 + i, added) } + } + } + val last = if (replaceCurrent) index + rest.lastIndex else index + rest.size + if (tail.isEmpty()) selection.focusAt(last, cursorToEnd = true) else selection.focusAt(last, caret = caret) + return if (replaceCurrent) "" else lead +} + +/** A heading is one line: line breaks of pasted text become spaces there. */ +private fun String.flattenFor(block: TextualBlock): String = if (block is Block.HBlock) replace('\n', ' ') else this + +/** A block of [this] block's kind (paragraph, quote or heading of the same level) holding [text]. */ +private fun TextualBlock.sameKind(text: String): Block = when (this) { + is Block.HBlock -> Block.HBlock(MutableStateFlow(text.replace('\n', ' ')), blockType) + is Block.QuoteBlock -> Block.QuoteBlock(MutableStateFlow(text)) + else -> Block.TextBlock(MutableStateFlow(text)) +} diff --git a/app/src/main/java/com/pasich/encly/presentation/editor/state/Shortcut.kt b/app/src/main/java/com/pasich/encly/presentation/editor/state/Shortcut.kt index 15ce1113..b4f3d18b 100644 --- a/app/src/main/java/com/pasich/encly/presentation/editor/state/Shortcut.kt +++ b/app/src/main/java/com/pasich/encly/presentation/editor/state/Shortcut.kt @@ -18,7 +18,6 @@ private val NUMBER_MARK = Regex("^\\d{1,3}[.)] ") private val BULLET_MARKS = listOf("- ", "* ", "• ") private val OPEN_CHECK_MARKS = listOf("[] ", "[ ] ", "- [ ] ", "- [] ") private val DONE_CHECK_MARKS = listOf("[x] ", "[X] ", "- [x] ", "- [X] ") -private val RULES = setOf("---", "***", "___") private const val QUOTE_MARK = "> " private const val TYPED_RULE = "---" @@ -78,37 +77,6 @@ private val LONE_WEB_ADDRESS = Regex("^https?://[^\\s\\\\]+$", RegexOption.IGNOR /** Whether [line] is a web address and nothing else: pasted alone, it becomes a link card. */ fun isLoneWebAddress(line: String): Boolean = LONE_WEB_ADDRESS.matches(line.trim()) -/** - * Pasted [lines] as blocks: marks become their blocks, a lone web address a link, a rule a - * separator, anything else a paragraph. Blank lines only separate paragraphs, and consecutive - * items of one kind of list become one list. - */ -fun pastedBlocks(lines: List): List { - val result = mutableListOf() - for (line in lines) { - if (line.isBlank()) continue - val block = pastedBlock(line) - val previous = result.lastOrNull() - if (block is Block.ListBlock && previous is Block.ListBlock && previous.blockType == block.blockType) { - previous.items.value += block.items.value - } else { - result += block - } - } - return result -} - -private fun pastedBlock(line: String): Block { - val trimmed = line.trim() - val shortcut = leadingShortcut(line) - return when { - trimmed in RULES -> Block.SeparatorBlock() - isLoneWebAddress(trimmed) -> Block.LinkBlock(MutableStateFlow(LinkDataBlock(title = "", url = trimmed))) - shortcut != null -> shortcutBlock(shortcut, line.removePrefix(shortcut.marker)) - else -> Block.TextBlock(MutableStateFlow(line)) - } -} - /** A pasted line as a list item's text: without a list mark of its own. */ fun listItemText(line: String): ItemListBlock { val shortcut = leadingShortcut(line)?.takeIf { it.type in LIST_TYPES } ?: return ItemListBlock(line) diff --git a/app/src/main/java/com/pasich/encly/presentation/editor/state/SlashCommands.kt b/app/src/main/java/com/pasich/encly/presentation/editor/state/SlashCommands.kt new file mode 100644 index 00000000..083052e9 --- /dev/null +++ b/app/src/main/java/com/pasich/encly/presentation/editor/state/SlashCommands.kt @@ -0,0 +1,146 @@ +package com.pasich.encly.presentation.editor.state + +import androidx.annotation.StringRes +import com.pasich.encly.R +import com.pasich.encly.dynamicBlocks.Block +import com.pasich.encly.dynamicBlocks.BlockType +import com.pasich.encly.dynamicBlocks.factory.BlockFactory +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +// The slash command picker: "/" typed at the start of an empty paragraph offers the block kinds +// the editor already has. Everything here is plain state; the popup and its labels are UI. + +/** + * A block kind the slash picker offers. [keywords] are extra words (English, for every + * language) the query may start with, next to the words of the localized label. [headingLevel] + * fills the "Heading %d" label. + */ +enum class SlashCommand( + val type: BlockType, + @param:StringRes val label: Int, + val headingLevel: Int? = null, + val keywords: List = emptyList(), +) { + TEXT(BlockType.TEXT, R.string.text, keywords = listOf("paragraph", "plain")), + H1(BlockType.H1, R.string.block_heading_level, headingLevel = 1, keywords = listOf("h1", "title")), + H2(BlockType.H2, R.string.block_heading_level, headingLevel = 2, keywords = listOf("h2")), + H3(BlockType.H3, R.string.block_heading_level, headingLevel = 3, keywords = listOf("h3")), + H4(BlockType.H4, R.string.block_heading_level, headingLevel = 4, keywords = listOf("h4")), + CHECKLIST(BlockType.LIST_CHECK, R.string.block_checklist, keywords = listOf("todo", "task", "checkbox")), + BULLETS(BlockType.LIST_BULLET, R.string.block_bulleted_list, keywords = listOf("bullets", "ul")), + NUMBERED(BlockType.LIST_NUMBER, R.string.block_numbered_list, keywords = listOf("ordered", "ol")), + QUOTE(BlockType.QUOTE, R.string.quote, keywords = listOf("citation")), + LINK(BlockType.LINK, R.string.block_link, keywords = listOf("url", "bookmark")), + DIVIDER(BlockType.SEPARATOR, R.string.block_separator, keywords = listOf("divider", "rule", "line", "hr")), +} + +/** + * The commands whose label ([labels], by command; a missing one counts as empty) or keywords + * have a word that starts with [query], ignoring case, in the order of [SlashCommand]. An empty + * query matches every command. + */ +fun slashMatches(query: String, labels: Map): List { + if (query.isEmpty()) return SlashCommand.entries + return SlashCommand.entries.filter { command -> + val words = labels[command].orEmpty().split(' ') + command.keywords + words.any { it.startsWith(query, ignoreCase = true) } + } +} + +/** + * The picker as the UI shows it, on paragraph [blockId] whose text is "/" + [query]. It stays + * hidden until the "/" is [committed] text (not under IME composition). + */ +data class SlashMenu( + val blockId: String, + val query: String, + val matches: List, + val highlighted: Int, + val committed: Boolean, +) { + /** The command Enter would pick. */ + val highlightedCommand: SlashCommand? get() = matches.getOrNull(highlighted) + + /** Whether the user can see and use the menu. */ + val isShown: Boolean get() = committed && matches.isNotEmpty() +} + +/** + * Which paragraph has the slash picker open, and what has been typed after the "/". Only ever + * in memory. A space, a query nothing matches, or any text that no longer starts with "/" + * closes it; the literal text stays as typed. + */ +class SlashPicker { + private val _menu = MutableStateFlow(null) + val menu: StateFlow = _menu.asStateFlow() + + private var labels: Map = emptyMap() + + /** The localized label of every command, for matching the query; set by the UI. */ + fun setLabels(labels: Map) { + this.labels = labels + _menu.value?.let { current -> _menu.value = current.withQuery(current.query) } + } + + /** "/" was typed into the empty paragraph [blockId]. */ + fun open(blockId: String) { + _menu.value = SlashMenu(blockId, "", slashMatches("", labels), highlighted = 0, committed = false) + } + + /** The "/" is committed text, so the menu may show. */ + fun commit() { + _menu.value = _menu.value?.copy(committed = true) + } + + /** Paragraph [blockId] now reads [text]: the query follows it, or the menu closes. */ + fun onText(blockId: String, text: String) { + val current = _menu.value?.takeIf { it.blockId == blockId } ?: return + val query = text.removePrefix("/") + val stillSlash = text.startsWith("/") && query.none { it.isWhitespace() } + val next = if (stillSlash) current.withQuery(query) else null + _menu.value = next?.takeIf { it.matches.isNotEmpty() } + } + + /** Moves the highlight by [delta] places, wrapping round. */ + fun moveHighlight(delta: Int) { + val current = _menu.value?.takeIf { it.matches.isNotEmpty() } ?: return + val size = current.matches.size + _menu.value = current.copy(highlighted = ((current.highlighted + delta) % size + size) % size) + } + + fun dismiss() { + _menu.value = null + } + + private fun SlashMenu.withQuery(query: String): SlashMenu = + copy(query = query, matches = slashMatches(query, labels), highlighted = 0) +} + +/** + * Turns the empty-but-for-"/query" paragraph [blockId] into [command]'s block, as one undo step + * (undo brings the paragraph back with the "/query" as typed). "Text" only clears the query; + * a divider is followed by a paragraph to go on in. Returns false when [blockId] is not a + * paragraph. + */ +fun BlockEditorState.applySlash(blockId: String, command: SlashCommand): Boolean { + val index = indexOf(blockId) + val block = blocks.getOrNull(index) as? Block.TextBlock ?: return false + when (command.type) { + BlockType.TEXT -> { + batch { setValue(block.id, block.text, "") } + selection.focusAt(index, caret = 0) + } + + BlockType.SEPARATOR -> replaceWithBlockAndParagraph(index, Block.SeparatorBlock()) + + else -> { + val replacement = BlockFactory.createBlock(command.type) + batch { replaceBlock(index, replacement) } + val item = (replacement as? Block.ListBlock)?.items?.value?.firstOrNull() + selection.focusAt(index, caret = 0, itemId = item?.id) + } + } + return true +} diff --git a/app/src/main/java/com/pasich/encly/presentation/editor/state/SmartPaste.kt b/app/src/main/java/com/pasich/encly/presentation/editor/state/SmartPaste.kt new file mode 100644 index 00000000..fd738a0c --- /dev/null +++ b/app/src/main/java/com/pasich/encly/presentation/editor/state/SmartPaste.kt @@ -0,0 +1,52 @@ +package com.pasich.encly.presentation.editor.state + +import com.pasich.encly.data.importer.MarkdownBlockMapper +import com.pasich.encly.domain.model.LinkDataBlock +import com.pasich.encly.dynamicBlocks.Block +import com.pasich.encly.dynamicBlocks.BlockType +import kotlinx.coroutines.flow.MutableStateFlow + +// Smart Paste: what pasted plain text becomes. Only the plain-text clipboard is read; HTML is never parsed. + +/** + * [lines] without the whitespace-only lines before the first and after the last one that has + * content. Blank lines between content stay: they are the author's paragraph breaks. + */ +fun trimBlankLines(lines: List): List { + val first = lines.indexOfFirst { it.isNotBlank() } + if (first < 0) return emptyList() + return lines.subList(first, lines.indexOfLast { it.isNotBlank() } + 1) +} + +/** + * Pasted [lines] as blocks, read as Markdown by the importer's [MarkdownBlockMapper] in its + * paste mode (nothing is rewritten or dropped): marks become their blocks, a lone web address a + * link, a rule a separator, anything else a paragraph. A blank line (any number of them) starts + * the next paragraph while a single line break stays inside its paragraph, and consecutive + * items of one kind of list become one list. Whitespace-only lines at either end are not + * content and are dropped; none in between is. With [plainFirstLine] the first line stays plain + * text, marks and all: a paste that continues what is already on the line must not turn it into + * a heading, list or link card. + */ +fun pastedBlocks(lines: List, plainFirstLine: Boolean = false): List { + val text = trimBlankLines(lines).withIndex() + .joinToString("\n") { (i, line) -> if (i == 0 && plainFirstLine) line else asMarkdownMark(line) } + return MarkdownBlockMapper.pasteBlocks(text, plainFirstLine, ::pastedAddress) +} + +/** [line] with the editor's own short marks ("[] ", "[x] ", "• ") written the way Markdown does. */ +private fun asMarkdownMark(line: String): String { + val shortcut = leadingShortcut(line) ?: return line + val rest by lazy { line.removePrefix(shortcut.marker) } + return when { + shortcut.type == BlockType.LIST_CHECK && !shortcut.marker.startsWith("-") -> + (if (shortcut.checked) "- [x] " else "- [ ] ") + rest + + shortcut.marker == "• " -> "- $rest" + + else -> line + } +} + +private fun pastedAddress(line: String): Block? = line.trim().takeIf(::isLoneWebAddress) + ?.let { Block.LinkBlock(MutableStateFlow(LinkDataBlock(title = "", url = it))) } diff --git a/app/src/main/java/com/pasich/encly/presentation/navigation/NavHost.kt b/app/src/main/java/com/pasich/encly/presentation/navigation/NavHost.kt index 626c070f..1c466f29 100644 --- a/app/src/main/java/com/pasich/encly/presentation/navigation/NavHost.kt +++ b/app/src/main/java/com/pasich/encly/presentation/navigation/NavHost.kt @@ -4,7 +4,6 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.hilt.navigation.compose.hiltViewModel import androidx.navigation.NavBackStackEntry import androidx.navigation.NavHostController import androidx.navigation.NavType @@ -12,6 +11,8 @@ import androidx.navigation.compose.NavHost import androidx.navigation.navArgument import com.pasich.encly.BuildConfig import com.pasich.encly.presentation.effects.animationScreens +import com.pasich.encly.presentation.effects.axisEnter +import com.pasich.encly.presentation.effects.axisExit import com.pasich.encly.presentation.screen.AboutScreen import com.pasich.encly.presentation.screen.EditTagScreen import com.pasich.encly.presentation.screen.FaqScreen @@ -25,18 +26,28 @@ import com.pasich.encly.presentation.screen.SupportScreen import com.pasich.encly.presentation.screen.TasksScreen import com.pasich.encly.presentation.screen.backup.BackupScreen import com.pasich.encly.presentation.screen.editnote.EditNoteScreen +import com.pasich.encly.presentation.screen.importnotes.ImportNotesScreen import com.pasich.encly.presentation.screen.onboarding.OnboardingScreen import com.pasich.encly.presentation.screen.settings.AppearanceScreen +import com.pasich.encly.presentation.screen.settings.DuressPinScreen import com.pasich.encly.presentation.screen.settings.SecuritySettingsScreen import com.pasich.encly.presentation.screen.settings.SettingsScreen import com.pasich.encly.presentation.screen.settings.WipePinScreen import com.pasich.encly.presentation.screen.trash.TrashScreen import com.pasich.encly.presentation.viewmodel.EditNoteViewModel import com.pasich.encly.presentation.viewmodel.LossRecoveryViewModel +import com.pasich.encly.presentation.viewmodel.enclyViewModel @Composable fun AppNavHost(navController: NavHostController, startDestination: String = NavRoutes.HomeRoute.name) { - NavHost(navController, startDestination = startDestination) { + NavHost( + navController, + startDestination = startDestination, + // The back gesture plays the same shared-axis motion as a tap on Back. Navigation's own + // default for the gesture (since 2.10) shrinks the leaving screen into the middle of the window. + predictivePopEnterTransition = { axisEnter(forward = false) }, + predictivePopExitTransition = { axisExit(forward = false) }, + ) { animationScreens(NavRoutes.HomeRoute.name) { MainRootScreen( navController, @@ -108,6 +119,10 @@ fun AppNavHost(navController: NavHostController, startDestination: String = NavR BackupScreen(navController) } + animationScreens(NavRoutes.ImportNotesRoute.name) { + ImportNotesScreen(navController) + } + animationScreens(NavRoutes.OnboardingRoute.name) { // Onboarding includes the mandatory PIN setup; it completes only once the vault // is committed and open. @@ -132,6 +147,10 @@ fun AppNavHost(navController: NavHostController, startDestination: String = NavR WipePinScreen(navController) } + animationScreens(NavRoutes.DuressPinRoute.name) { + DuressPinScreen(navController) + } + animationScreens(NavRoutes.LockRoute.name) { LockScreen(navController) } @@ -173,7 +192,7 @@ fun AppNavHost(navController: NavHostController, startDestination: String = NavR private fun EditNoteDestination( navController: NavHostController, entry: NavBackStackEntry, - viewModel: EditNoteViewModel = hiltViewModel(), + viewModel: EditNoteViewModel = enclyViewModel(), ) { val noteState by viewModel.state.collectAsState() val eraseEpoch = LocalEraseEpoch.current @@ -195,7 +214,7 @@ private fun LossRoute( navController: NavHostController, route: NavRoutes, reason: LossReason, - recoveryViewModel: LossRecoveryViewModel = hiltViewModel(), + recoveryViewModel: LossRecoveryViewModel = enclyViewModel(), ) { LossRecoveryScreen( reason = reason, diff --git a/app/src/main/java/com/pasich/encly/presentation/navigation/NavRoutes.kt b/app/src/main/java/com/pasich/encly/presentation/navigation/NavRoutes.kt index 04dbe6a9..69cf840b 100644 --- a/app/src/main/java/com/pasich/encly/presentation/navigation/NavRoutes.kt +++ b/app/src/main/java/com/pasich/encly/presentation/navigation/NavRoutes.kt @@ -16,10 +16,12 @@ enum class NavRoutes { LegacyVaultRoute, PinCodeConfig, WipePinRoute, + DuressPinRoute, LockRoute, BackupRoute, AppearanceRoute, LicensesRoute, + ImportNotesRoute, } /** Query argument of [NavRoutes.TasksRoute]: open the new-task sheet on arrival. */ diff --git a/app/src/main/java/com/pasich/encly/presentation/screen/EditTagScreen.kt b/app/src/main/java/com/pasich/encly/presentation/screen/EditTagScreen.kt index 67622359..2494c46a 100644 --- a/app/src/main/java/com/pasich/encly/presentation/screen/EditTagScreen.kt +++ b/app/src/main/java/com/pasich/encly/presentation/screen/EditTagScreen.kt @@ -51,7 +51,6 @@ import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp -import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.compose.LocalLifecycleOwner @@ -80,6 +79,7 @@ import com.pasich.encly.presentation.viewmodel.TAG_NAME_MAX_LENGTH import com.pasich.encly.presentation.viewmodel.TagListEvent import com.pasich.encly.presentation.viewmodel.TagListViewModel import com.pasich.encly.presentation.viewmodel.TagOperationFailure +import com.pasich.encly.presentation.viewmodel.enclyViewModel import com.pasich.encly.presentation.viewmodel.hasTagNamed import com.pasich.encly.presentation.viewmodel.normalizeTagName import com.pasich.encly.ui.theme.EnclyTheme @@ -102,7 +102,7 @@ private val TagNameKeyboard = KeyboardOptions(keyboardType = KeyboardType.Passwo fun EditTagScreen( navController: NavHostController, modifier: Modifier = Modifier, - viewModel: TagListViewModel = hiltViewModel(), + viewModel: TagListViewModel = enclyViewModel(), ) { val state by viewModel.state.collectAsStateWithLifecycle() val counts by viewModel.noteCounts.collectAsStateWithLifecycle() diff --git a/app/src/main/java/com/pasich/encly/presentation/screen/LockScreen.kt b/app/src/main/java/com/pasich/encly/presentation/screen/LockScreen.kt index 89832713..b3ec4c2a 100644 --- a/app/src/main/java/com/pasich/encly/presentation/screen/LockScreen.kt +++ b/app/src/main/java/com/pasich/encly/presentation/screen/LockScreen.kt @@ -18,7 +18,6 @@ import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.res.stringResource import androidx.fragment.app.FragmentActivity -import androidx.hilt.navigation.compose.hiltViewModel import androidx.navigation.NavHostController import com.pasich.encly.R import com.pasich.encly.core.security.PIN_LENGTH @@ -41,13 +40,14 @@ import com.pasich.encly.presentation.screen.pincode.pinLockoutText import com.pasich.encly.presentation.viewmodel.LockViewModel import com.pasich.encly.presentation.viewmodel.PinUnlockResult import com.pasich.encly.presentation.viewmodel.SeedUnlockResult +import com.pasich.encly.presentation.viewmodel.enclyViewModel import com.pasich.encly.ui.theme.EnclyTheme /** The app's lock screen: unlocking opens Home (or the note that was open), see [leaveLockScreen]. */ @Composable fun LockScreen(navController: NavHostController, modifier: Modifier = Modifier) { // The same instance the inner LockScreen gets: both come from this back-stack entry. - val canReopenNote = hiltViewModel()::canReopenNote + val canReopenNote = enclyViewModel()::canReopenNote val unlockReveal = LocalUnlockReveal.current val exits = remember(navController, unlockReveal) { LockExits( @@ -71,7 +71,7 @@ fun LockScreen(navController: NavHostController, modifier: Modifier = Modifier) } @Composable -fun LockScreen(exits: LockExits, modifier: Modifier = Modifier, viewModel: LockViewModel = hiltViewModel()) { +fun LockScreen(exits: LockExits, modifier: Modifier = Modifier, viewModel: LockViewModel = enclyViewModel()) { val activity = LocalActivity.current as? FragmentActivity val busy by viewModel.busy.collectAsState() // Above the loading view below: the forms' input and errors survive the credential check. diff --git a/app/src/main/java/com/pasich/encly/presentation/screen/MainScreen.kt b/app/src/main/java/com/pasich/encly/presentation/screen/MainScreen.kt index 338350f6..78778f63 100644 --- a/app/src/main/java/com/pasich/encly/presentation/screen/MainScreen.kt +++ b/app/src/main/java/com/pasich/encly/presentation/screen/MainScreen.kt @@ -35,7 +35,6 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.layout.layout import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.stringResource -import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavHostController import androidx.navigation.NavOptions @@ -54,6 +53,7 @@ import com.pasich.encly.presentation.viewmodel.MainListStateViewModel import com.pasich.encly.presentation.viewmodel.NoteListViewModel import com.pasich.encly.presentation.viewmodel.NoteSearchViewModel import com.pasich.encly.presentation.viewmodel.SettingsViewModel +import com.pasich.encly.presentation.viewmodel.enclyViewModel import com.pasich.encly.ui.theme.EnclyTheme import kotlinx.coroutines.launch @@ -113,11 +113,11 @@ fun MainScreen( navController: NavHostController, drawerState: DrawerState, modifier: Modifier = Modifier, - mainListStateViewModel: MainListStateViewModel = hiltViewModel(), - settingsViewModel: SettingsViewModel = hiltViewModel(), - noteListViewModel: NoteListViewModel = hiltViewModel(), - searchViewModel: NoteSearchViewModel = hiltViewModel(), - lockNowViewModel: LockNowViewModel = hiltViewModel(), + mainListStateViewModel: MainListStateViewModel = enclyViewModel(), + settingsViewModel: SettingsViewModel = enclyViewModel(), + noteListViewModel: NoteListViewModel = enclyViewModel(), + searchViewModel: NoteSearchViewModel = enclyViewModel(), + lockNowViewModel: LockNowViewModel = enclyViewModel(), ) { val isGrid by mainListStateViewModel.isGridNoteList.collectAsState() val showTasks by settingsViewModel.showTasksFlow.collectAsState() diff --git a/app/src/main/java/com/pasich/encly/presentation/screen/NotesListScreen.kt b/app/src/main/java/com/pasich/encly/presentation/screen/NotesListScreen.kt index 5baa4d07..6d0255e8 100644 --- a/app/src/main/java/com/pasich/encly/presentation/screen/NotesListScreen.kt +++ b/app/src/main/java/com/pasich/encly/presentation/screen/NotesListScreen.kt @@ -31,7 +31,6 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource -import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.pasich.encly.R import com.pasich.encly.core.common.LoadState @@ -52,6 +51,7 @@ import com.pasich.encly.presentation.viewmodel.NoteListState import com.pasich.encly.presentation.viewmodel.NoteListViewModel import com.pasich.encly.presentation.viewmodel.NoteSearchViewModel import com.pasich.encly.presentation.viewmodel.NotesEmptyState +import com.pasich.encly.presentation.viewmodel.enclyViewModel import com.pasich.encly.ui.theme.EnclyTheme import kotlinx.coroutines.delay import kotlinx.coroutines.launch @@ -76,7 +76,7 @@ fun NotesList( search: NotesSearch = NotesSearch("", NoteSearchViewModel.SearchResults()), onSecondActionClick: (Note) -> Unit = {}, header: @Composable () -> Unit = {}, - noteListViewModel: NoteListViewModel = hiltViewModel(), + noteListViewModel: NoteListViewModel = enclyViewModel(), ) { val state by noteListViewModel.state.collectAsStateWithLifecycle() val sheet = remember { NoteSheetState() } diff --git a/app/src/main/java/com/pasich/encly/presentation/screen/PinCodeConfigScreen.kt b/app/src/main/java/com/pasich/encly/presentation/screen/PinCodeConfigScreen.kt index 27cadfdc..4b70d21d 100644 --- a/app/src/main/java/com/pasich/encly/presentation/screen/PinCodeConfigScreen.kt +++ b/app/src/main/java/com/pasich/encly/presentation/screen/PinCodeConfigScreen.kt @@ -37,9 +37,9 @@ import androidx.compose.ui.draw.scale import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics -import androidx.hilt.navigation.compose.hiltViewModel import androidx.navigation.NavHostController import com.pasich.encly.R +import com.pasich.encly.core.security.PinChange import com.pasich.encly.core.security.SensitiveDataCleaner import com.pasich.encly.presentation.designsystem.EnclyIconTile import com.pasich.encly.presentation.designsystem.EnclyIcons @@ -53,6 +53,7 @@ import com.pasich.encly.presentation.screen.pincode.PinLockoutTicker import com.pasich.encly.presentation.screen.pincode.lockoutSecondsLeft import com.pasich.encly.presentation.screen.pincode.pinLockoutText import com.pasich.encly.presentation.viewmodel.SecuritySettingsViewModel +import com.pasich.encly.presentation.viewmodel.enclyViewModel import com.pasich.encly.ui.theme.EnclyTheme import kotlinx.coroutines.delay @@ -116,11 +117,11 @@ private class PinChangeState(val isReset: Boolean) { val matches = first != null && pin.contentEquals(first) first?.let(SensitiveDataCleaner::clear) if (matches) { - viewModel.activationPinAuth(pin) { ok -> - if (ok) { - animationState = PinAnimationState.SuccessAnimation - } else { - restartNewPin(R.string.pin_update_failed) + viewModel.activationPinAuth(pin) { result -> + when (result) { + PinChange.SET -> animationState = PinAnimationState.SuccessAnimation + PinChange.IN_USE -> restartNewPin(R.string.pin_in_use) + PinChange.FAILED -> restartNewPin(R.string.pin_update_failed) } } } else { @@ -169,7 +170,7 @@ private class PinChangeState(val isReset: Boolean) { fun PinCodeConfigScreen( navController: NavHostController, modifier: Modifier = Modifier, - securityViewModel: SecuritySettingsViewModel = hiltViewModel(), + securityViewModel: SecuritySettingsViewModel = enclyViewModel(), ) { // After a recovery-phrase unlock the old PIN is forgotten: start at "new PIN", and do not // let the user leave without one. diff --git a/app/src/main/java/com/pasich/encly/presentation/screen/TagsListScreen.kt b/app/src/main/java/com/pasich/encly/presentation/screen/TagsListScreen.kt index 94e4275a..1b4fb222 100644 --- a/app/src/main/java/com/pasich/encly/presentation/screen/TagsListScreen.kt +++ b/app/src/main/java/com/pasich/encly/presentation/screen/TagsListScreen.kt @@ -13,7 +13,6 @@ import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource -import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.pasich.encly.R import com.pasich.encly.core.common.LoadState @@ -21,6 +20,7 @@ import com.pasich.encly.data.model.Tag import com.pasich.encly.presentation.components.tiles.TagItem import com.pasich.encly.presentation.viewmodel.TagListEvent.SelectTag import com.pasich.encly.presentation.viewmodel.TagListViewModel +import com.pasich.encly.presentation.viewmodel.enclyViewModel import com.pasich.encly.ui.theme.EnclyTheme /** @@ -28,7 +28,7 @@ import com.pasich.encly.ui.theme.EnclyTheme * scrolling row that runs to the screen edges. */ @Composable -fun TagsList(modifier: Modifier = Modifier, tagListViewModel: TagListViewModel = hiltViewModel()) { +fun TagsList(modifier: Modifier = Modifier, tagListViewModel: TagListViewModel = enclyViewModel()) { val state by tagListViewModel.state.collectAsStateWithLifecycle() val gutter = EnclyTheme.spacing.listGutter diff --git a/app/src/main/java/com/pasich/encly/presentation/screen/TasksScreen.kt b/app/src/main/java/com/pasich/encly/presentation/screen/TasksScreen.kt index 5e1df587..cbe6dff2 100644 --- a/app/src/main/java/com/pasich/encly/presentation/screen/TasksScreen.kt +++ b/app/src/main/java/com/pasich/encly/presentation/screen/TasksScreen.kt @@ -28,7 +28,6 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource -import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.compose.LocalLifecycleOwner @@ -43,6 +42,7 @@ import com.pasich.encly.presentation.dialogs.tasks.AddTaskDialog import com.pasich.encly.presentation.viewmodel.TaskOperationFailure import com.pasich.encly.presentation.viewmodel.TasksUiState import com.pasich.encly.presentation.viewmodel.TasksViewModel +import com.pasich.encly.presentation.viewmodel.enclyViewModel import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.launch @@ -57,7 +57,7 @@ fun TasksScreen( navController: NavHostController, modifier: Modifier = Modifier, openNewTask: Boolean = false, - viewModel: TasksViewModel = hiltViewModel(), + viewModel: TasksViewModel = enclyViewModel(), ) { val uiState by viewModel.uiState.collectAsState() val snackbarHostState = remember { SnackbarHostState() } diff --git a/app/src/main/java/com/pasich/encly/presentation/screen/backup/BackupScreen.kt b/app/src/main/java/com/pasich/encly/presentation/screen/backup/BackupScreen.kt index 01cc48e4..d474892e 100644 --- a/app/src/main/java/com/pasich/encly/presentation/screen/backup/BackupScreen.kt +++ b/app/src/main/java/com/pasich/encly/presentation/screen/backup/BackupScreen.kt @@ -28,7 +28,6 @@ import androidx.compose.runtime.rememberUpdatedState import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource -import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavHostController import com.pasich.encly.R @@ -45,6 +44,7 @@ import com.pasich.encly.presentation.viewmodel.BackupMessage import com.pasich.encly.presentation.viewmodel.BackupStep import com.pasich.encly.presentation.viewmodel.BackupUiState import com.pasich.encly.presentation.viewmodel.BackupViewModel +import com.pasich.encly.presentation.viewmodel.enclyViewModel import com.pasich.encly.ui.theme.EnclyTheme import com.pasich.encly.utils.formatNoteDate import kotlinx.coroutines.launch @@ -61,7 +61,7 @@ import java.util.Locale fun BackupScreen( navController: NavHostController, modifier: Modifier = Modifier, - viewModel: BackupViewModel = hiltViewModel(), + viewModel: BackupViewModel = enclyViewModel(), ) { val state by viewModel.uiState.collectAsStateWithLifecycle() val snackbarHostState = remember { SnackbarHostState() } diff --git a/app/src/main/java/com/pasich/encly/presentation/screen/editnote/EditNoteLocalProvider.kt b/app/src/main/java/com/pasich/encly/presentation/screen/editnote/EditNoteLocalProvider.kt index 9b26f9c6..9fe235da 100644 --- a/app/src/main/java/com/pasich/encly/presentation/screen/editnote/EditNoteLocalProvider.kt +++ b/app/src/main/java/com/pasich/encly/presentation/screen/editnote/EditNoteLocalProvider.kt @@ -8,7 +8,10 @@ import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.sp import com.pasich.encly.domain.model.FontStyleType +import com.pasich.encly.domain.model.NoteLineSpacing import com.pasich.encly.dynamicBlocks.BlockType +import com.pasich.encly.presentation.editor.blocks.LocalSlashLabels +import com.pasich.encly.presentation.editor.blocks.rememberSlashLabels import com.pasich.encly.ui.theme.fontSet import com.pasich.encly.utils.FontSizeUtils @@ -38,6 +41,9 @@ val LocalFontStyle = compositionLocalOf { EditNoteDefaults.DEFAULT_FONT_STYLE } */ val LocalSimpleEdit = compositionLocalOf { EditNoteDefaults.DEFAULT_SIMPLE_EDIT } +/** The editor's paragraph and wrapped-line spacing. */ +val LocalNoteLineSpacing = compositionLocalOf { NoteLineSpacing.DEFAULT } + /** * CompositionLocal for precomputed font styles (optimization) */ @@ -51,6 +57,7 @@ fun EditNoteSettingsProvider( baseFontSize: TextUnit = EditNoteDefaults.DEFAULT_FONT_SIZE, fontStyle: FontStyleType = EditNoteDefaults.DEFAULT_FONT_STYLE, simpleEdit: Boolean = EditNoteDefaults.DEFAULT_SIMPLE_EDIT, + lineSpacing: NoteLineSpacing = NoteLineSpacing.DEFAULT, content: @Composable () -> Unit, ) { // Pre-compute font styles for better performance @@ -63,7 +70,9 @@ fun EditNoteSettingsProvider( LocalBaseFontSize provides baseFontSize, LocalFontStyle provides fontStyle, LocalSimpleEdit provides simpleEdit, + LocalNoteLineSpacing provides lineSpacing, LocalFontStyles provides fontStyles, + LocalSlashLabels provides rememberSlashLabels(), content = content, ) } diff --git a/app/src/main/java/com/pasich/encly/presentation/screen/editnote/EditNoteScreen.kt b/app/src/main/java/com/pasich/encly/presentation/screen/editnote/EditNoteScreen.kt index 934f39e2..743289b3 100644 --- a/app/src/main/java/com/pasich/encly/presentation/screen/editnote/EditNoteScreen.kt +++ b/app/src/main/java/com/pasich/encly/presentation/screen/editnote/EditNoteScreen.kt @@ -3,6 +3,7 @@ package com.pasich.encly.presentation.screen.editnote import androidx.activity.compose.BackHandler import androidx.compose.foundation.LocalOverscrollFactory import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize @@ -28,12 +29,13 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavBackStackEntry @@ -46,16 +48,20 @@ import com.pasich.encly.presentation.designsystem.CalloutTone import com.pasich.encly.presentation.designsystem.EnclyCallout import com.pasich.encly.presentation.designsystem.EnclySnackbarHost import com.pasich.encly.presentation.designsystem.NoteSkeleton +import com.pasich.encly.presentation.designsystem.blockGap +import com.pasich.encly.presentation.designsystem.isCompact import com.pasich.encly.presentation.dialogs.ConfirmDialog import com.pasich.encly.presentation.dialogs.EditNoteBottomSheet import com.pasich.encly.presentation.dialogs.EditNoteBottomSheetAction import com.pasich.encly.presentation.editor.EditorBlocksHost +import com.pasich.encly.presentation.editor.FindHighlights import com.pasich.encly.presentation.editor.editorBlocks import com.pasich.encly.presentation.editor.focus.rememberBlockFocusRegistry import com.pasich.encly.presentation.editor.persistence.SaveStatusNote import com.pasich.encly.presentation.navigation.NavRoutes import com.pasich.encly.presentation.viewmodel.EditNoteViewModel import com.pasich.encly.presentation.viewmodel.TagListViewModel +import com.pasich.encly.presentation.viewmodel.enclyViewModel import com.pasich.encly.ui.theme.EnclyTheme import kotlinx.coroutines.launch @@ -63,24 +69,25 @@ import kotlinx.coroutines.launch private const val HEADER_ITEMS = 2 @Composable +@Suppress("LongMethod") // Screen state and editor settings feed one scoped editor composition. fun EditNoteScreen( navController: NavHostController, modifier: Modifier = Modifier, - viewModel: EditNoteViewModel = hiltViewModel(), + viewModel: EditNoteViewModel = enclyViewModel(), ) { - val context = LocalContext.current val scope = rememberCoroutineScope() val focusManager = LocalFocusManager.current val keyboardController = LocalSoftwareKeyboardController.current + val resources = LocalResources.current val lifecycleOwner = LocalLifecycleOwner.current val snackbarHostState = remember { SnackbarHostState() } - val exits = remember(viewModel, navController) { + val exits = remember(viewModel, navController, resources) { EditorExits( viewModel = viewModel, navController = navController, entry = lifecycleOwner as? NavBackStackEntry, scope = scope, - onFailure = { message -> scope.launch { snackbarHostState.showSnackbar(context.getString(message)) } }, + onFailure = { message -> scope.launch { snackbarHostState.showSnackbar(resources.getString(message)) } }, beforeLeaving = { keyboardController?.hide() focusManager.clearFocus() @@ -89,23 +96,36 @@ fun EditNoteScreen( } val fontSize by viewModel.fontSize.collectAsStateWithLifecycle() val fontStyle by viewModel.fontStyle.collectAsStateWithLifecycle() + val lineSpacing by viewModel.noteLineSpacing.collectAsStateWithLifecycle() val simpleEdit by viewModel.simpleEdit.collectAsStateWithLifecycle() val contentLoadFailed by viewModel.contentLoadFailed.collectAsState() val overlays = rememberEditorOverlays() val listState = rememberLazyListState() val focusRegistry = rememberBlockFocusRegistry(viewModel.blocks) - val host = remember(viewModel, focusRegistry) { - EditorBlocksHost(viewModel, focusRegistry) { type, block -> overlays.blockSheet.open(type, block.id) } + val findState = viewModel.find.collectAsState() + val highlights = remember(findState) { FindHighlights(findState) } + val host = remember(viewModel, focusRegistry, highlights) { + EditorBlocksHost(viewModel, focusRegistry, highlights) { type, block -> + overlays.blockSheet.open(type, block.id) + } } val titleFocus = remember { FocusRequester() } SaveWhenPaused() - CarryOutFocusRequests(focusRegistry, listState, blocksStart = HEADER_ITEMS + if (contentLoadFailed) 1 else 0) + val blocksStart = HEADER_ITEMS + if (contentLoadFailed) 1 else 0 + CarryOutFocusRequests(focusRegistry, listState, blocksStart = blocksStart) + ScrollToFindMatch(listState, blocksStart) FocusNewNoteTitle(titleFocus) BackHandler { exits.back() } + CloseFindOrSlashOnBack() EditorOverlays(overlays, exits, onManageTags = { navController.navigate(NavRoutes.EditTagRoute.name) }) - EditNoteSettingsProvider(baseFontSize = fontSize.sp, fontStyle = fontStyle, simpleEdit = simpleEdit) { + EditNoteSettingsProvider( + baseFontSize = fontSize.sp, + fontStyle = fontStyle, + simpleEdit = simpleEdit, + lineSpacing = lineSpacing, + ) { EditorScaffold( actions = EditorBarActions( onBack = exits::back, @@ -140,26 +160,30 @@ private fun EditorScaffold( listState: LazyListState, snackbarHostState: SnackbarHostState, modifier: Modifier = Modifier, - viewModel: EditNoteViewModel = hiltViewModel(), + viewModel: EditNoteViewModel = enclyViewModel(), content: @Composable (PaddingValues) -> Unit, ) { val lockEditor by viewModel.lockEditor.collectAsState() val contentLoadFailed by viewModel.contentLoadFailed.collectAsState() + val findOpen by rememberFindOpen() Scaffold( - modifier = modifier, + modifier = modifier.onPreviewKeyEvent { event -> viewModel.handleFindKey(event) }, containerColor = MaterialTheme.colorScheme.surface, // Status and navigation bars, a display cutout and the keyboard all stay clear of text. contentWindowInsets = WindowInsets.safeDrawing, topBar = { - EditorTopBar( - state = EditorBarState( - isReadTrashOnly = viewModel.isReadTrashOnly, - lockEditor = lockEditor, - canToggleLock = !contentLoadFailed, - ), - listState = listState, - actions = actions, - ) + Column { + EditorTopBar( + state = EditorBarState( + isReadTrashOnly = viewModel.isReadTrashOnly, + lockEditor = lockEditor, + canToggleLock = !contentLoadFailed, + ), + listState = listState, + actions = actions, + ) + if (findOpen) EditorFindBar() + } }, bottomBar = { if (!lockEditor && !viewModel.isReadTrashOnly) NoteBottomBar(simpleEdit = LocalSimpleEdit.current) @@ -195,7 +219,7 @@ private fun EditorOverlays( overlays: EditorOverlaysState, exits: EditorExits, onManageTags: () -> Unit, - viewModel: EditNoteViewModel = hiltViewModel(), + viewModel: EditNoteViewModel = enclyViewModel(), ) { val lockEditor by viewModel.lockEditor.collectAsState() EditorConfirmDialogs( @@ -230,6 +254,8 @@ private fun EditorOverlays( EditNoteBottomSheetAction.DUPLICATE -> exits.duplicate() + EditNoteBottomSheetAction.FIND -> viewModel.openFind() + EditNoteBottomSheetAction.TRASH -> exits.trash() } }, @@ -243,11 +269,14 @@ private fun EditorContent( listState: LazyListState, titleFocus: FocusRequester, modifier: Modifier = Modifier, - viewModel: EditNoteViewModel = hiltViewModel(), + viewModel: EditNoteViewModel = enclyViewModel(), ) { + val spacing = LocalNoteLineSpacing.current + val headerGap = if (spacing.isCompact) EnclyTheme.spacing.labelGap else 0.dp val lockEditor by viewModel.lockEditor.collectAsState() val contentLoadFailed by viewModel.contentLoadFailed.collectAsState() val editingLinkIds by viewModel.editingLinkIds.collectAsState() + val templatesOffered by viewModel.templatesOffered.collectAsState() // Only whether it is loading: a save changing the status must not recompose the list. val status = viewModel.status.collectAsState() val loading by remember(status) { derivedStateOf { status.value == SaveStatusNote.LOADING } } @@ -255,8 +284,8 @@ private fun EditorContent( CompositionLocalProvider(LocalOverscrollFactory provides null) { LazyColumn( state = listState, - // With a block's 4 dp frame inset above and below, blocks sit 14 apart (spec §4.4). - verticalArrangement = Arrangement.spacedBy(EnclyTheme.spacing.labelGap), + // Default block frames and this gap give 14 dp between blocks (spec §4.4). + verticalArrangement = Arrangement.spacedBy(spacing.blockGap(EnclyTheme.spacing.labelGap)), contentPadding = PaddingValues(top = EnclyTheme.spacing.xxs), modifier = modifier.fillMaxSize(), ) { @@ -266,18 +295,36 @@ private fun EditorContent( } return@LazyColumn } - item(key = "overline") { EditorOverline() } - item(key = "title") { EditorTitle(readOnly = lockEditor, modifier = Modifier.focusRequester(titleFocus)) } + item(key = "overline") { + EditorOverline(modifier = Modifier.padding(bottom = headerGap)) + } + item(key = "title") { + EditorTitle( + readOnly = lockEditor, + modifier = Modifier + .focusRequester(titleFocus) + .padding(bottom = headerGap), + ) + } if (contentLoadFailed) { item(key = "unreadable") { EnclyCallout( text = stringResource(R.string.note_load_failed), tone = CalloutTone.WARNING, - modifier = Modifier.padding(horizontal = EnclyTheme.spacing.gutter), + modifier = Modifier.padding( + horizontal = EnclyTheme.spacing.gutter, + vertical = headerGap, + ), ) } } - editorBlocks(host, viewModel.blocks, isLocked = lockEditor, editingLinkIds = editingLinkIds) + editorBlocks( + host, + viewModel.blocks, + isLocked = lockEditor, + editingLinkIds = editingLinkIds, + afterBlocks = if (templatesOffered) ({ TemplateChooser() }) else null, + ) } } } @@ -287,7 +334,7 @@ private fun EditorContent( private fun EditorTitle( readOnly: Boolean, modifier: Modifier = Modifier, - viewModel: EditNoteViewModel = hiltViewModel(), + viewModel: EditNoteViewModel = enclyViewModel(), ) { TitleField( title = { viewModel.state.value.note.title }, @@ -303,8 +350,8 @@ private fun EditorTitle( @Composable private fun EditorOverline( modifier: Modifier = Modifier, - viewModel: EditNoteViewModel = hiltViewModel(), - tagViewModel: TagListViewModel = hiltViewModel(), + viewModel: EditNoteViewModel = enclyViewModel(), + tagViewModel: TagListViewModel = enclyViewModel(), ) { // Read through derived state: a title keystroke or a save does not recompose the overline. val noteState = viewModel.state.collectAsState() diff --git a/app/src/main/java/com/pasich/encly/presentation/screen/editnote/EditorEffects.kt b/app/src/main/java/com/pasich/encly/presentation/screen/editnote/EditorEffects.kt index 72a32d6e..ba3c3abc 100644 --- a/app/src/main/java/com/pasich/encly/presentation/screen/editnote/EditorEffects.kt +++ b/app/src/main/java/com/pasich/encly/presentation/screen/editnote/EditorEffects.kt @@ -1,11 +1,14 @@ package com.pasich.encly.presentation.screen.editnote +import androidx.activity.compose.BackHandler import androidx.compose.foundation.gestures.animateScrollBy import androidx.compose.foundation.lazy.LazyListState import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.MutableState +import androidx.compose.runtime.State +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -14,15 +17,24 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.runtime.withFrameNanos import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEvent +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.isCtrlPressed +import androidx.compose.ui.input.key.isMetaPressed +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.type import androidx.compose.ui.platform.LocalSoftwareKeyboardController -import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.compose.LocalLifecycleOwner import com.pasich.encly.domain.enums.BottomSheetsOpenType import com.pasich.encly.presentation.editor.focus.BlockFocusRegistry import com.pasich.encly.presentation.viewmodel.EditNoteViewModel +import com.pasich.encly.presentation.viewmodel.enclyViewModel import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map private const val INITIAL_FOCUS_FRAMES = 5 @@ -31,7 +43,7 @@ private const val INITIAL_FOCUS_FRAMES = 5 * closes SQLCipher later in the background transition, so this closes the autosave window first. */ @Composable -internal fun SaveWhenPaused(viewModel: EditNoteViewModel = hiltViewModel()) { +internal fun SaveWhenPaused(viewModel: EditNoteViewModel = enclyViewModel()) { val lifecycleOwner = LocalLifecycleOwner.current DisposableEffect(lifecycleOwner, viewModel) { val observer = LifecycleEventObserver { _, event -> @@ -52,7 +64,7 @@ internal fun CarryOutFocusRequests( registry: BlockFocusRegistry, listState: LazyListState, blocksStart: Int, - viewModel: EditNoteViewModel = hiltViewModel(), + viewModel: EditNoteViewModel = enclyViewModel(), ) { val currentBlocksStart by rememberUpdatedState(blocksStart) LaunchedEffect(viewModel, registry) { @@ -89,7 +101,7 @@ private const val REVEAL_FRACTION = 4 /** A new note starts in its title with the keyboard up: once, not again after a restore. */ @Composable -internal fun FocusNewNoteTitle(titleFocus: FocusRequester, viewModel: EditNoteViewModel = hiltViewModel()) { +internal fun FocusNewNoteTitle(titleFocus: FocusRequester, viewModel: EditNoteViewModel = enclyViewModel()) { val keyboardController = LocalSoftwareKeyboardController.current var done by rememberSaveable { mutableStateOf(!viewModel.isNewNote) } LaunchedEffect(done) { @@ -128,3 +140,51 @@ internal fun rememberBlockSheetState(): BlockSheetState { val blockId = rememberSaveable { mutableStateOf(null) } return remember { BlockSheetState(type, blockId) } } + +/** Whether the find bar is open. */ +@Composable +internal fun rememberFindOpen(viewModel: EditNoteViewModel = enclyViewModel()): State { + val open = remember(viewModel) { viewModel.find.map { it.isOpen }.distinctUntilChanged() } + return open.collectAsState(initial = false) +} + +/** + * Scrolls the list to the block of the match Find is on, when that block is off screen. Nothing + * gets focus and nothing is edited. + */ +@Composable +internal fun ScrollToFindMatch( + listState: LazyListState, + blocksStart: Int, + viewModel: EditNoteViewModel = enclyViewModel(), +) { + val current = remember(viewModel) { viewModel.find.map { it.currentMatch }.distinctUntilChanged() } + val match by current.collectAsState(initial = null) + val currentBlocksStart by rememberUpdatedState(blocksStart) + LaunchedEffect(match) { + val blockId = match?.blockId ?: return@LaunchedEffect + val index = viewModel.blocks.indexOfFirst { it.id == blockId } + if (index >= 0) listState.reveal(currentBlocksStart + index) + } +} + +/** Back closes the find bar, else an open slash picker, before it leaves the note. */ +@Composable +internal fun CloseFindOrSlashOnBack(viewModel: EditNoteViewModel = enclyViewModel()) { + val findOpen by rememberFindOpen() + val slashMenu by viewModel.slashPicker.menu.collectAsState() + val slashShown = slashMenu?.isShown == true + BackHandler(enabled = findOpen || slashShown) { + if (findOpen) viewModel.closeFind() else viewModel.slashPicker.dismiss() + } +} + +/** Hardware keyboard: Ctrl or Cmd+F opens Find, Escape closes it. True when the key was used. */ +internal fun EditNoteViewModel.handleFindKey(event: KeyEvent): Boolean { + if (event.type != KeyEventType.KeyDown) return false + return when { + event.key == Key.F && (event.isCtrlPressed || event.isMetaPressed) -> true.also { openFind() } + event.key == Key.Escape && find.value.isOpen -> true.also { closeFind() } + else -> false + } +} diff --git a/app/src/main/java/com/pasich/encly/presentation/screen/editnote/EditorFindBar.kt b/app/src/main/java/com/pasich/encly/presentation/screen/editnote/EditorFindBar.kt new file mode 100644 index 00000000..195883e4 --- /dev/null +++ b/app/src/main/java/com/pasich/encly/presentation/screen/editnote/EditorFindBar.kt @@ -0,0 +1,100 @@ +package com.pasich.encly.presentation.screen.editnote + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.withFrameNanos +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.LiveRegionMode +import androidx.compose.ui.semantics.liveRegion +import androidx.compose.ui.semantics.semantics +import com.pasich.encly.R +import com.pasich.encly.presentation.designsystem.EnclyIcons +import com.pasich.encly.presentation.designsystem.EnclySearchField +import com.pasich.encly.presentation.editor.state.FindState +import com.pasich.encly.presentation.viewmodel.EditNoteViewModel +import com.pasich.encly.presentation.viewmodel.enclyViewModel +import com.pasich.encly.ui.theme.EnclyTheme + +private const val FOCUS_FRAMES = 3 + +/** + * Find in this note: the query, "n of m" and previous/next, under the editor bar. Read-only, so it + * also works in a locked or trashed note. The query lives in the ViewModel and nowhere else (not in + * saved state), and the whole bar is gone when the vault locks. + */ +@Composable +internal fun EditorFindBar(modifier: Modifier = Modifier, viewModel: EditNoteViewModel = enclyViewModel()) { + val state by viewModel.find.collectAsState() + val focus = remember { FocusRequester() } + LaunchedEffect(focus) { + // The field is in the tree after a frame or two; asking for focus brings the keyboard up. + repeat(FOCUS_FRAMES) { withFrameNanos { } } + runCatching { focus.requestFocus() } + } + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(EnclyTheme.spacing.xxs), + modifier = modifier + .fillMaxWidth() + .windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Horizontal)) + .padding(horizontal = EnclyTheme.spacing.xs, vertical = EnclyTheme.spacing.xxs), + ) { + EnclySearchField( + value = state.query, + onValueChange = viewModel::onFindQueryChanged, + placeholder = stringResource(R.string.find_placeholder), + modifier = Modifier.weight(1f), + fieldModifier = Modifier.focusRequester(focus), + onSubmit = viewModel::findNext, + trailing = { MatchCount(state) }, + ) + val hasMatches = state.matches.isNotEmpty() + IconButton(onClick = viewModel::findPrevious, enabled = hasMatches) { + Icon(EnclyIcons.ArrowUp, contentDescription = stringResource(R.string.find_previous)) + } + IconButton(onClick = viewModel::findNext, enabled = hasMatches) { + Icon(EnclyIcons.ArrowDown, contentDescription = stringResource(R.string.find_next)) + } + IconButton(onClick = viewModel::closeFind) { + Icon(EnclyIcons.Close, contentDescription = stringResource(R.string.find_close)) + } + } +} + +/** "2 of 5", "No matches" once something is typed, nothing before; announced as it changes. */ +@Composable +private fun MatchCount(state: FindState, modifier: Modifier = Modifier) { + val text = when { + state.query.isBlank() -> return + state.matches.isEmpty() -> stringResource(R.string.find_no_matches) + else -> stringResource(R.string.find_count, state.current + 1, state.matches.size) + } + Text( + text = text, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = modifier + .padding(horizontal = EnclyTheme.spacing.xs) + .semantics { liveRegion = LiveRegionMode.Polite }, + ) +} diff --git a/app/src/main/java/com/pasich/encly/presentation/screen/editnote/EditorSheets.kt b/app/src/main/java/com/pasich/encly/presentation/screen/editnote/EditorSheets.kt index ca1232f2..b0b36d18 100644 --- a/app/src/main/java/com/pasich/encly/presentation/screen/editnote/EditorSheets.kt +++ b/app/src/main/java/com/pasich/encly/presentation/screen/editnote/EditorSheets.kt @@ -32,7 +32,6 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource -import androidx.hilt.navigation.compose.hiltViewModel import com.pasich.encly.R import com.pasich.encly.domain.enums.BottomSheetsOpenType import com.pasich.encly.dynamicBlocks.Block @@ -47,6 +46,7 @@ import com.pasich.encly.presentation.dialogs.blocks.SettingsBlockDialog import com.pasich.encly.presentation.editor.state.BlockRemoveAction import com.pasich.encly.presentation.viewmodel.EditNoteViewModel import com.pasich.encly.presentation.viewmodel.TagListViewModel +import com.pasich.encly.presentation.viewmodel.enclyViewModel import com.pasich.encly.ui.theme.EnclyTheme import kotlinx.coroutines.launch @@ -59,7 +59,7 @@ import kotlinx.coroutines.launch internal fun EditorBlockSheets( sheet: BlockSheetState, canEdit: Boolean, - viewModel: EditNoteViewModel = hiltViewModel(), + viewModel: EditNoteViewModel = enclyViewModel(), ) { val context = LocalContext.current val scope = rememberCoroutineScope() @@ -74,6 +74,7 @@ internal fun EditorBlockSheets( fun onAction(action: ActionBlockDialog) { val link = (block as? Block.LinkBlock)?.block?.value?.url.orEmpty() when (action) { + ActionBlockDialog.Duplicate -> viewModel.duplicateBlock(block) ActionBlockDialog.Delete -> viewModel.removeBlock(block, BlockRemoveAction.REMOVE) is ActionBlockDialog.Move -> viewModel.moveBlock(action.move == 1) ActionBlockDialog.OpenLink -> openNoteLink(context, link) @@ -111,8 +112,8 @@ internal fun EditorBlockSheets( internal fun EditorTagSheet( onManageTags: () -> Unit, onDismiss: () -> Unit, - viewModel: EditNoteViewModel = hiltViewModel(), - tagViewModel: TagListViewModel = hiltViewModel(), + viewModel: EditNoteViewModel = enclyViewModel(), + tagViewModel: TagListViewModel = enclyViewModel(), ) { val tagState by tagViewModel.state.collectAsState() val noteState by viewModel.state.collectAsState() diff --git a/app/src/main/java/com/pasich/encly/presentation/screen/editnote/TemplateChooser.kt b/app/src/main/java/com/pasich/encly/presentation/screen/editnote/TemplateChooser.kt new file mode 100644 index 00000000..843f6b15 --- /dev/null +++ b/app/src/main/java/com/pasich/encly/presentation/screen/editnote/TemplateChooser.kt @@ -0,0 +1,48 @@ +package com.pasich.encly.presentation.screen.editnote + +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalResources +import androidx.compose.ui.res.stringResource +import com.pasich.encly.R +import com.pasich.encly.presentation.designsystem.EnclyChip +import com.pasich.encly.presentation.designsystem.SectionOverline +import com.pasich.encly.presentation.editor.state.NoteTemplate +import com.pasich.encly.presentation.editor.state.blocks +import com.pasich.encly.presentation.viewmodel.EditNoteViewModel +import com.pasich.encly.presentation.viewmodel.enclyViewModel +import com.pasich.encly.ui.theme.EnclyTheme + +/** + * "Start from template" under a brand-new, still empty note: Blank (what a new note is anyway, + * shown selected) and the ready outlines. Picking one fills the note with ordinary blocks, one undo + * step; not picking changes nothing, and the row is gone with the first character typed. + */ +@Composable +internal fun TemplateChooser(modifier: Modifier = Modifier, viewModel: EditNoteViewModel = enclyViewModel()) { + val resources = LocalResources.current + Column( + verticalArrangement = Arrangement.spacedBy(EnclyTheme.spacing.xs), + modifier = modifier.padding(horizontal = EnclyTheme.spacing.gutter, vertical = EnclyTheme.spacing.s), + ) { + SectionOverline(stringResource(R.string.template_start)) + Row( + horizontalArrangement = Arrangement.spacedBy(EnclyTheme.spacing.xs), + modifier = Modifier.horizontalScroll(rememberScrollState()), + ) { + NoteTemplate.entries.forEach { template -> + EnclyChip( + label = stringResource(template.label), + selected = template == NoteTemplate.BLANK, + onClick = { viewModel.applyTemplate(template.blocks { id -> resources.getString(id) }) }, + ) + } + } + } +} diff --git a/app/src/main/java/com/pasich/encly/presentation/screen/handoff/ImportFromMyNotesScreen.kt b/app/src/main/java/com/pasich/encly/presentation/screen/handoff/ImportFromMyNotesScreen.kt index ab381f80..634e7fc2 100644 --- a/app/src/main/java/com/pasich/encly/presentation/screen/handoff/ImportFromMyNotesScreen.kt +++ b/app/src/main/java/com/pasich/encly/presentation/screen/handoff/ImportFromMyNotesScreen.kt @@ -19,7 +19,6 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource -import androidx.hilt.navigation.compose.hiltViewModel import com.pasich.encly.R import com.pasich.encly.data.handoff.HandoffError import com.pasich.encly.data.handoff.HandoffPreview @@ -34,6 +33,7 @@ import com.pasich.encly.presentation.screen.LockScreen import com.pasich.encly.presentation.screen.pincode.AuthLoading import com.pasich.encly.presentation.viewmodel.HandoffStep import com.pasich.encly.presentation.viewmodel.ImportFromMyNotesViewModel +import com.pasich.encly.presentation.viewmodel.enclyViewModel import com.pasich.encly.ui.theme.EnclyTheme /** @@ -44,7 +44,7 @@ import com.pasich.encly.ui.theme.EnclyTheme fun ImportFromMyNotesScreen( onClose: () -> Unit, modifier: Modifier = Modifier, - viewModel: ImportFromMyNotesViewModel = hiltViewModel(), + viewModel: ImportFromMyNotesViewModel = enclyViewModel(), ) { val locked by viewModel.locked.collectAsState() val step by viewModel.step.collectAsState() diff --git a/app/src/main/java/com/pasich/encly/presentation/screen/importnotes/ImportNotesActions.kt b/app/src/main/java/com/pasich/encly/presentation/screen/importnotes/ImportNotesActions.kt new file mode 100644 index 00000000..bc137660 --- /dev/null +++ b/app/src/main/java/com/pasich/encly/presentation/screen/importnotes/ImportNotesActions.kt @@ -0,0 +1,17 @@ +package com.pasich.encly.presentation.screen.importnotes + +import com.pasich.encly.data.importer.ImportSource +import com.pasich.encly.presentation.viewmodel.ReviewFilter + +/** What the page's buttons do; the screen wires them to the ViewModel and the system pickers. */ +class ImportNotesActions( + val onChooseFiles: () -> Unit, + val onChooseFolder: () -> Unit, + val onImport: () -> Unit, + val onCancel: () -> Unit, + val onChooseSource: (ImportSource) -> Unit, + val onToggle: (String) -> Unit = {}, + val onToggleAll: () -> Unit = {}, + val onFilter: (ReviewFilter) -> Unit = {}, + val onExpand: (String) -> Unit = {}, +) diff --git a/app/src/main/java/com/pasich/encly/presentation/screen/importnotes/ImportNotesScreen.kt b/app/src/main/java/com/pasich/encly/presentation/screen/importnotes/ImportNotesScreen.kt new file mode 100644 index 00000000..3ff58e89 --- /dev/null +++ b/app/src/main/java/com/pasich/encly/presentation/screen/importnotes/ImportNotesScreen.kt @@ -0,0 +1,126 @@ +package com.pasich.encly.presentation.screen.importnotes + +import android.content.ActivityNotFoundException +import androidx.activity.compose.BackHandler +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.NavHostController +import com.pasich.encly.R +import com.pasich.encly.presentation.designsystem.EnclyTopBar +import com.pasich.encly.presentation.screen.pincode.AuthLoading +import com.pasich.encly.presentation.viewmodel.ImportNotesViewModel +import com.pasich.encly.presentation.viewmodel.ImportStep +import com.pasich.encly.presentation.viewmodel.enclyViewModel +import com.pasich.encly.ui.theme.EnclyTheme + +/** + * Settings → Import notes. Works in a decoy session too: it imports into whichever vault is + * open. The files are another app's, so the picker is opened with the vault kept open for it. + */ +@Composable +fun ImportNotesScreen( + navController: NavHostController, + modifier: Modifier = Modifier, + viewModel: ImportNotesViewModel = enclyViewModel(), +) { + val step by viewModel.step.collectAsStateWithLifecycle() + val files = rememberLauncherForActivityResult( + ActivityResultContracts.OpenMultipleDocuments(), + viewModel::onFilesPicked, + ) + val folder = + rememberLauncherForActivityResult(ActivityResultContracts.OpenDocumentTree(), viewModel::onFolderPicked) + val actions = remember(viewModel, files, folder) { + ImportNotesActions( + onChooseFiles = { launchPicker(viewModel) { files.launch(arrayOf("*/*")) } }, + onChooseFolder = { launchPicker(viewModel) { folder.launch(null) } }, + onImport = viewModel::confirm, + onCancel = viewModel::cancel, + onChooseSource = viewModel::chooseSource, + onToggle = viewModel::toggle, + onToggleAll = viewModel::toggleAll, + onFilter = viewModel::setFilter, + onExpand = viewModel::expand, + ) + } + // The import is one short transaction; leaving in the middle would only roll it back. + BackHandler(enabled = step == ImportStep.Importing) {} + BackHandler(enabled = step != ImportStep.Start && step != ImportStep.Importing) { viewModel.cancel() } + ImportNotesContent( + step = step, + actions = actions, + onBack = { navController.popBackStack() }, + modifier = modifier, + ) +} + +/** Keeps the vault open for the picker only once it has really started (no picker app: a message). */ +private fun launchPicker(viewModel: ImportNotesViewModel, launch: () -> Unit) { + try { + launch() + } catch (_: ActivityNotFoundException) { + viewModel.onPickerUnavailable() + return + } + viewModel.onPickerLaunched() +} + +/** The page for a [step]; stateless, so every state can be shown and tested on its own. */ +@Composable +fun ImportNotesContent( + step: ImportStep, + actions: ImportNotesActions, + onBack: () -> Unit, + modifier: Modifier = Modifier, +) { + when (step) { + ImportStep.Reading -> AuthLoading(stringResource(R.string.import_reading), modifier) + + ImportStep.Importing -> AuthLoading(stringResource(R.string.import_importing), modifier) + + is ImportStep.Preview -> ImportReviewScreen(step.preview, step.review, actions, modifier) + + else -> Scaffold( + modifier = modifier, + containerColor = MaterialTheme.colorScheme.surface, + topBar = { + EnclyTopBar( + title = stringResource(R.string.import_title), + onBack = if (step == ImportStep.Start) onBack else actions.onCancel, + ) + }, + ) { padding -> + Column( + modifier = Modifier + .padding(padding) + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .navigationBarsPadding() + .padding(horizontal = EnclyTheme.spacing.gutter, vertical = EnclyTheme.spacing.s), + verticalArrangement = Arrangement.spacedBy(EnclyTheme.spacing.section), + ) { + when (step) { + ImportStep.Start -> StartPage(actions) + ImportStep.ChooseSource -> ChooseSourcePage(actions) + is ImportStep.Done -> DonePage(step.result, actions) + is ImportStep.Failed -> FailedPage(step.failure, actions) + } + } + } + } +} diff --git a/app/src/main/java/com/pasich/encly/presentation/screen/importnotes/ImportPages.kt b/app/src/main/java/com/pasich/encly/presentation/screen/importnotes/ImportPages.kt new file mode 100644 index 00000000..c9132529 --- /dev/null +++ b/app/src/main/java/com/pasich/encly/presentation/screen/importnotes/ImportPages.kt @@ -0,0 +1,127 @@ +package com.pasich.encly.presentation.screen.importnotes + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import com.pasich.encly.R +import com.pasich.encly.data.importer.ImportLosses +import com.pasich.encly.data.importer.ImportPreview +import com.pasich.encly.data.importer.ImportSource +import com.pasich.encly.presentation.designsystem.EnclyButton +import com.pasich.encly.presentation.designsystem.EnclyCallout +import com.pasich.encly.presentation.designsystem.EnclyTextButton +import com.pasich.encly.presentation.designsystem.EnclyTonalButton +import com.pasich.encly.presentation.designsystem.SectionOverline +import com.pasich.encly.presentation.designsystem.StepHeading +import com.pasich.encly.presentation.viewmodel.ImportFailure +import com.pasich.encly.presentation.viewmodel.ImportResult +import com.pasich.encly.ui.theme.EnclyTheme + +@Composable +internal fun StartPage(actions: ImportNotesActions) { + Column(verticalArrangement = Arrangement.spacedBy(EnclyTheme.spacing.section)) { + Text( + text = stringResource(R.string.import_intro), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Column(verticalArrangement = Arrangement.spacedBy(EnclyTheme.spacing.s)) { + EnclyButton(text = stringResource(R.string.import_choose_files), onClick = actions.onChooseFiles) + EnclyTonalButton(text = stringResource(R.string.import_choose_folder), onClick = actions.onChooseFolder) + } + Column(verticalArrangement = Arrangement.spacedBy(EnclyTheme.spacing.s)) { + SectionOverline(text = stringResource(R.string.import_section_supported)) + Column(verticalArrangement = Arrangement.spacedBy(EnclyTheme.spacing.cardGap)) { + ImportSource.entries.forEach { SourceCard(it) } + } + } + } +} + +@Composable +internal fun Labelled(label: String, text: String, modifier: Modifier = Modifier) { + Column(modifier = modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(EnclyTheme.spacing.textGap)) { + Text( + text = label.uppercase(), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text(text = text, style = MaterialTheme.typography.bodySmall) + } +} + +@Composable +internal fun previewLines(preview: ImportPreview): List = buildList { + add(stringResource(R.string.import_stat_checklists, preview.checklists)) + add(stringResource(R.string.import_stat_tags, preview.tags)) + if (preview.trashed > 0) add(stringResource(R.string.import_stat_trashed, preview.trashed)) + if (preview.duplicates > 0) add(stringResource(R.string.import_stat_duplicates, preview.duplicates)) + if (preview.duplicatesInSelection > 0) { + add(stringResource(R.string.import_stat_repeated, preview.duplicatesInSelection)) + } +} + +@Composable +internal fun lossLines(preview: ImportPreview): List { + val losses: ImportLosses = preview.losses + return buildList { + if (losses.attachments > 0) add(stringResource(R.string.import_loss_attachments, losses.attachments)) + if (losses.pinned > 0) add(stringResource(R.string.import_loss_pinned, losses.pinned)) + if (losses.archived > 0) add(stringResource(R.string.import_loss_archived, losses.archived)) + if (losses.colors > 0) add(stringResource(R.string.import_loss_colors, losses.colors)) + if (losses.extraTags > 0) add(stringResource(R.string.import_loss_tags, losses.extraTags)) + if (losses.todos > 0) add(stringResource(R.string.import_loss_todos, losses.todos)) + if (preview.unsupported > 0) add(stringResource(R.string.import_loss_unsupported, preview.unsupported)) + if (preview.filesSkipped > 0) add(stringResource(R.string.import_loss_files, preview.filesSkipped)) + if (preview.nestedArchives > 0) add(stringResource(R.string.import_loss_nested, preview.nestedArchives)) + } +} + +@Composable +internal fun ChooseSourcePage(actions: ImportNotesActions) { + StepHeading( + title = stringResource(R.string.import_choose_source_title), + body = stringResource(R.string.import_choose_source_body), + ) + ImportSource.entries.forEach { source -> + EnclyTonalButton(text = stringResource(source.title), onClick = { actions.onChooseSource(source) }) + } + EnclyTextButton( + text = stringResource(R.string.cancel), + onClick = actions.onCancel, + modifier = Modifier.fillMaxWidth(), + ) +} + +@Composable +internal fun DonePage(result: ImportResult, actions: ImportNotesActions) { + StepHeading( + title = stringResource(R.string.import_done_title), + body = stringResource(R.string.import_done_body, result.imported, result.duplicates, result.notImported), + ) + EnclyCallout(text = stringResource(R.string.import_done_hint)) + EnclyButton(text = stringResource(R.string.done), onClick = actions.onCancel) +} + +@Composable +internal fun FailedPage(failure: ImportFailure, actions: ImportNotesActions) { + StepHeading(title = stringResource(R.string.import_error_title), body = stringResource(errorText(failure))) + EnclyButton(text = stringResource(R.string.import_choose_again), onClick = actions.onCancel) +} + +internal fun errorText(failure: ImportFailure): Int = when (failure) { + ImportFailure.ENCRYPTED_STANDARD_NOTES -> R.string.import_error_encrypted_sn + ImportFailure.ENCRYPTED_NOTESNOOK -> R.string.import_error_encrypted_nn + ImportFailure.UNSUPPORTED -> R.string.import_error_unsupported + ImportFailure.TOO_LARGE -> R.string.import_error_too_large + ImportFailure.NOTHING_FOUND -> R.string.import_error_nothing + ImportFailure.UNREADABLE -> R.string.import_error_unreadable + ImportFailure.NO_PICKER -> R.string.import_error_no_picker + ImportFailure.VAULT -> R.string.import_error_vault + ImportFailure.IMPORT -> R.string.import_error_failed +} diff --git a/app/src/main/java/com/pasich/encly/presentation/screen/importnotes/ImportReviewScreen.kt b/app/src/main/java/com/pasich/encly/presentation/screen/importnotes/ImportReviewScreen.kt new file mode 100644 index 00000000..645e245d --- /dev/null +++ b/app/src/main/java/com/pasich/encly/presentation/screen/importnotes/ImportReviewScreen.kt @@ -0,0 +1,212 @@ +package com.pasich.encly.presentation.screen.importnotes + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.Checkbox +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import com.pasich.encly.R +import com.pasich.encly.data.importer.ImportPreview +import com.pasich.encly.data.importer.ReviewNote +import com.pasich.encly.presentation.designsystem.CalloutTone +import com.pasich.encly.presentation.designsystem.EnclyButton +import com.pasich.encly.presentation.designsystem.EnclyCallout +import com.pasich.encly.presentation.designsystem.EnclyCard +import com.pasich.encly.presentation.designsystem.EnclyCardStyle +import com.pasich.encly.presentation.designsystem.EnclyChip +import com.pasich.encly.presentation.designsystem.EnclyTextButton +import com.pasich.encly.presentation.designsystem.EnclyTopBar +import com.pasich.encly.presentation.designsystem.StepHeading +import com.pasich.encly.presentation.viewmodel.ReviewFilter +import com.pasich.encly.presentation.viewmodel.ReviewState +import com.pasich.encly.ui.theme.EnclyTheme +import com.pasich.encly.utils.formatNoteDate +import java.util.Date + +/** + * The review step before an import: the detected source with its counts and losses, then every + * note with a checkbox. Only ticked notes are imported; a note already in the vault is shown but + * cannot be ticked. Read-only: titles and snippets are shown here and nowhere else, and nothing + * on this page is saved in instance state, copied or shared. + */ +@Composable +fun ImportReviewScreen( + preview: ImportPreview, + review: ReviewState, + actions: ImportNotesActions, + modifier: Modifier = Modifier, +) { + Scaffold( + modifier = modifier, + containerColor = MaterialTheme.colorScheme.surface, + topBar = { EnclyTopBar(title = stringResource(R.string.import_title), onBack = actions.onCancel) }, + bottomBar = { + Column( + modifier = Modifier + .navigationBarsPadding() + .padding(horizontal = EnclyTheme.spacing.gutter, vertical = EnclyTheme.spacing.s), + verticalArrangement = Arrangement.spacedBy(EnclyTheme.spacing.xs), + ) { + EnclyButton( + text = stringResource(R.string.import_action_import_count, review.selected.size), + onClick = actions.onImport, + enabled = review.selected.isNotEmpty(), + ) + EnclyTextButton( + text = stringResource(R.string.cancel), + onClick = actions.onCancel, + modifier = Modifier.fillMaxWidth(), + ) + } + }, + ) { padding -> + LazyColumn( + modifier = Modifier.padding(padding), + contentPadding = PaddingValues(horizontal = EnclyTheme.spacing.gutter, vertical = EnclyTheme.spacing.s), + verticalArrangement = Arrangement.spacedBy(EnclyTheme.spacing.s), + ) { + item { Summary(preview) } + item { Controls(review, actions) } + items(review.visible, key = { it.uid }) { note -> + NoteRow( + note = note, + checked = note.uid in review.selected, + expandedText = review.expandedText.takeIf { review.expanded == note.uid }, + actions = actions, + ) + } + } + } +} + +@Composable +private fun Summary(preview: ImportPreview) { + Column(verticalArrangement = Arrangement.spacedBy(EnclyTheme.spacing.section)) { + StepHeading( + title = stringResource(R.string.import_preview_title), + body = stringResource( + R.string.import_detected, + stringResource(preview.source.title), + pluralStringResource(R.plurals.backup_count_notes, preview.notes, preview.notes), + ), + ) + EnclyCard { + previewLines(preview).forEach { Text(text = it, style = MaterialTheme.typography.bodyMedium) } + Text( + text = stringResource(R.string.import_stat_destination), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + val losses = lossLines(preview) + if (losses.isNotEmpty()) { + EnclyCallout( + title = stringResource(R.string.import_label_losses), + text = losses.joinToString("\n"), + tone = CalloutTone.WARNING, + ) + } + Text( + text = stringResource(R.string.import_preview_body), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +/** "N of M selected", select all / none, and the All / New only / In trash filter. */ +@Composable +private fun Controls(review: ReviewState, actions: ImportNotesActions) { + Column(verticalArrangement = Arrangement.spacedBy(EnclyTheme.spacing.xs)) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween) { + Text( + text = stringResource(R.string.import_review_selected, review.selected.size, review.newCount), + style = MaterialTheme.typography.labelLarge, + modifier = Modifier.weight(1f), + ) + // Nothing new to pick (every note is already here): no select-all to offer. + if (review.newCount > 0) { + val all = review.selected.size == review.newCount + EnclyTextButton( + text = stringResource(if (all) R.string.import_select_none else R.string.import_select_all), + onClick = actions.onToggleAll, + ) + } + } + Row(horizontalArrangement = Arrangement.spacedBy(EnclyTheme.spacing.xs)) { + FilterChip(ReviewFilter.ALL, R.string.import_filter_all, review, actions) + FilterChip(ReviewFilter.NEW, R.string.import_filter_new, review, actions) + FilterChip(ReviewFilter.TRASH, R.string.import_filter_trash, review, actions) + } + } +} + +@Composable +private fun FilterChip(filter: ReviewFilter, label: Int, review: ReviewState, actions: ImportNotesActions) { + EnclyChip( + label = stringResource(label), + selected = review.filter == filter, + onClick = { actions.onFilter(filter) }, + ) +} + +@Composable +private fun NoteRow(note: ReviewNote, checked: Boolean, expandedText: String?, actions: ImportNotesActions) { + EnclyCard(style = EnclyCardStyle.OUTLINED, onClick = { actions.onExpand(note.uid) }) { + Row(verticalAlignment = Alignment.Top, horizontalArrangement = Arrangement.spacedBy(EnclyTheme.spacing.xs)) { + Checkbox( + checked = checked, + onCheckedChange = { actions.onToggle(note.uid) }, + enabled = !note.existing, + ) + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(EnclyTheme.spacing.textGap), + ) { + Text( + text = note.title.ifBlank { stringResource(R.string.import_untitled) }, + style = MaterialTheme.typography.titleSmall, + ) + if (note.snippet.isNotEmpty()) { + Text( + text = note.snippet, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + ) + } + Text( + text = badges(note), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.primary, + ) + if (expandedText != null) { + Text(text = expandedText, style = MaterialTheme.typography.bodySmall) + } + } + } + } +} + +/** Badges and date on one line: checklist, tag, in trash, already in the vault, then the date. */ +@Composable +private fun badges(note: ReviewNote): String = buildList { + if (note.hasChecklist) add(stringResource(R.string.import_badge_checklist)) + note.tag?.let { add("#$it") } + if (note.trashed) add(stringResource(R.string.import_badge_trash)) + if (note.existing) add(stringResource(R.string.import_badge_in_vault)) + add(formatNoteDate(Date(note.date))) +}.joinToString(" · ") diff --git a/app/src/main/java/com/pasich/encly/presentation/screen/importnotes/ImportSourceCard.kt b/app/src/main/java/com/pasich/encly/presentation/screen/importnotes/ImportSourceCard.kt new file mode 100644 index 00000000..c241a165 --- /dev/null +++ b/app/src/main/java/com/pasich/encly/presentation/screen/importnotes/ImportSourceCard.kt @@ -0,0 +1,103 @@ +package com.pasich.encly.presentation.screen.importnotes + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.res.stringResource +import com.pasich.encly.R +import com.pasich.encly.data.importer.ImportSource +import com.pasich.encly.presentation.designsystem.EnclyCard +import com.pasich.encly.presentation.designsystem.EnclyCardStyle +import com.pasich.encly.presentation.designsystem.EnclyIcons +import com.pasich.encly.ui.theme.EnclyTheme + +/** + * One supported app on the Import notes page: its mark, its name and what to pick. A tap anywhere + * on the card unfolds how to get the export out of the app and what will not come over, so the + * list stays short enough to scan. + */ +@Composable +internal fun SourceCard(source: ImportSource, modifier: Modifier = Modifier) { + var expanded by rememberSaveable { mutableStateOf(false) } + val turn by animateFloatAsState(if (expanded) CHEVRON_TURN else 0f, label = "chevron") + EnclyCard(modifier = modifier, style = EnclyCardStyle.OUTLINED, onClick = { expanded = !expanded }) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(EnclyTheme.spacing.rowGap), + ) { + SourceMark(source) + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(EnclyTheme.spacing.textGap), + ) { + Text(text = stringResource(source.title), style = MaterialTheme.typography.labelLarge) + Text( + text = stringResource(source.formats), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Icon( + EnclyIcons.ChevronDown, + contentDescription = stringResource(if (expanded) R.string.collapse else R.string.expand), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier + .size(EnclyTheme.spacing.iconSmall) + .rotate(turn), + ) + } + AnimatedVisibility(visible = expanded) { + Column( + modifier = Modifier.padding( + start = EnclyTheme.spacing.tileSmall + EnclyTheme.spacing.rowGap, + top = EnclyTheme.spacing.s, + ), + verticalArrangement = Arrangement.spacedBy(EnclyTheme.spacing.s), + ) { + Labelled(stringResource(R.string.import_label_how_to), stringResource(source.howTo)) + Labelled(stringResource(R.string.import_label_losses), stringResource(source.losses)) + } + } + } +} + +/** The app's initials on a small tile: a plain mark, never the app's own logo or colours. */ +@Composable +private fun SourceMark(source: ImportSource) { + Surface(shape = MaterialTheme.shapes.small, color = MaterialTheme.colorScheme.secondaryContainer) { + Box(contentAlignment = Alignment.Center, modifier = Modifier.size(EnclyTheme.spacing.tileSmall)) { + Text( + text = source.initials(), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSecondaryContainer, + ) + } + } +} + +private fun ImportSource.initials(): String = when (this) { + ImportSource.GOOGLE_KEEP -> "GK" + ImportSource.STANDARD_NOTES -> "SN" + ImportSource.JOPLIN -> "J" + ImportSource.NOTESNOOK -> "NN" + ImportSource.MARKDOWN -> "MD" +} + +private const val CHEVRON_TURN = 180f diff --git a/app/src/main/java/com/pasich/encly/presentation/screen/onboarding/OnboardingScreen.kt b/app/src/main/java/com/pasich/encly/presentation/screen/onboarding/OnboardingScreen.kt index 9e52342f..a0a263a0 100644 --- a/app/src/main/java/com/pasich/encly/presentation/screen/onboarding/OnboardingScreen.kt +++ b/app/src/main/java/com/pasich/encly/presentation/screen/onboarding/OnboardingScreen.kt @@ -39,7 +39,6 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.res.stringResource import androidx.fragment.app.FragmentActivity -import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.pasich.encly.R import com.pasich.encly.core.locale.AppLanguage @@ -56,6 +55,7 @@ import com.pasich.encly.presentation.viewmodel.OnboardingPath import com.pasich.encly.presentation.viewmodel.OnboardingStep import com.pasich.encly.presentation.viewmodel.OnboardingViewModel import com.pasich.encly.presentation.viewmodel.OnboardingViewModel.OnboardingUiState +import com.pasich.encly.presentation.viewmodel.enclyViewModel import com.pasich.encly.ui.theme.EnclyTheme private const val SLIDE_MS = 500 @@ -73,8 +73,8 @@ private const val FADE_OUT_MS = 300 fun OnboardingScreen( onComplete: () -> Unit, modifier: Modifier = Modifier, - viewModel: OnboardingViewModel = hiltViewModel(), - setupViewModel: AuthSetupViewModel = hiltViewModel(), + viewModel: OnboardingViewModel = enclyViewModel(), + setupViewModel: AuthSetupViewModel = enclyViewModel(), ) { val state by viewModel.uiState.collectAsStateWithLifecycle() val finishing by setupViewModel.busy.collectAsStateWithLifecycle() diff --git a/app/src/main/java/com/pasich/encly/presentation/screen/settings/AppearanceScreen.kt b/app/src/main/java/com/pasich/encly/presentation/screen/settings/AppearanceScreen.kt index 11fb3177..e691790a 100644 --- a/app/src/main/java/com/pasich/encly/presentation/screen/settings/AppearanceScreen.kt +++ b/app/src/main/java/com/pasich/encly/presentation/screen/settings/AppearanceScreen.kt @@ -22,7 +22,6 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight -import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavHostController import com.pasich.encly.R @@ -43,6 +42,7 @@ import com.pasich.encly.presentation.designsystem.Segment import com.pasich.encly.presentation.designsystem.ThemeSwatch import com.pasich.encly.presentation.viewmodel.AppearanceViewModel import com.pasich.encly.presentation.viewmodel.ModeNote +import com.pasich.encly.presentation.viewmodel.enclyViewModel import com.pasich.encly.ui.theme.EnclyTheme import com.pasich.encly.ui.theme.colorSchemeFor import com.pasich.encly.ui.theme.fontSet @@ -54,7 +54,7 @@ private const val DYNAMIC_SWATCH_ALPHA = 0.38f fun AppearanceScreen( navController: NavHostController, modifier: Modifier = Modifier, - viewModel: AppearanceViewModel = hiltViewModel(), + viewModel: AppearanceViewModel = enclyViewModel(), ) { val settings by viewModel.themeSettings.collectAsStateWithLifecycle() AppearanceContent( diff --git a/app/src/main/java/com/pasich/encly/presentation/screen/settings/AutoLockSetting.kt b/app/src/main/java/com/pasich/encly/presentation/screen/settings/AutoLockSetting.kt index 75ff6de1..65b94e23 100644 --- a/app/src/main/java/com/pasich/encly/presentation/screen/settings/AutoLockSetting.kt +++ b/app/src/main/java/com/pasich/encly/presentation/screen/settings/AutoLockSetting.kt @@ -67,6 +67,14 @@ private fun AutoLockDialog(current: AutoLockDelay, onDismiss: () -> Unit, onConf onClick = { selected = delay }, ) } + if (selected == AutoLockDelay.MINUTES_15 || selected == AutoLockDelay.MINUTES_30) { + Text( + text = stringResource(R.string.auto_lock_long_delay_warning), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = EnclyTheme.spacing.xs), + ) + } } } } @@ -77,4 +85,6 @@ private fun AutoLockDelay.label(): Int = when (this) { AutoLockDelay.SECONDS_30 -> R.string.auto_lock_30s AutoLockDelay.MINUTE_1 -> R.string.auto_lock_1m AutoLockDelay.MINUTES_2 -> R.string.auto_lock_2m + AutoLockDelay.MINUTES_15 -> R.string.auto_lock_15m + AutoLockDelay.MINUTES_30 -> R.string.auto_lock_30m } diff --git a/app/src/main/java/com/pasich/encly/presentation/screen/settings/DuressPinScreen.kt b/app/src/main/java/com/pasich/encly/presentation/screen/settings/DuressPinScreen.kt new file mode 100644 index 00000000..c5151d5b --- /dev/null +++ b/app/src/main/java/com/pasich/encly/presentation/screen/settings/DuressPinScreen.kt @@ -0,0 +1,172 @@ +package com.pasich.encly.presentation.screen.settings + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.NavHostController +import com.pasich.encly.R +import com.pasich.encly.core.security.DuressPinChange +import com.pasich.encly.presentation.designsystem.CalloutTone +import com.pasich.encly.presentation.designsystem.DialogAction +import com.pasich.encly.presentation.designsystem.EnclyButton +import com.pasich.encly.presentation.designsystem.EnclyCallout +import com.pasich.encly.presentation.designsystem.EnclyDialog +import com.pasich.encly.presentation.designsystem.EnclyTextButton +import com.pasich.encly.presentation.viewmodel.SecuritySettingsViewModel +import com.pasich.encly.presentation.viewmodel.enclyViewModel +import com.pasich.encly.ui.theme.EnclyTheme + +private val DuressPinTexts = PinFlowTexts( + title = R.string.duress_pin_title, + currentSubtitle = R.string.duress_pin_current_subtitle, + newTitle = R.string.duress_pin_new_title, + newSubtitle = R.string.duress_pin_new_subtitle, + confirmTitle = R.string.duress_pin_confirm_title, + confirmSubtitle = R.string.duress_pin_confirm_subtitle, +) + +/** + * Settings → Security → Duress PIN. Same flow as the wipe PIN: the real PIN first, then what the + * duress PIN does and whether one is set, then the new PIN typed twice. In a decoy session it + * opens too, and setting or turning it off fails like a storage error (see SecurityManager). + */ +@Composable +fun DuressPinScreen( + navController: NavHostController, + modifier: Modifier = Modifier, + securityViewModel: SecuritySettingsViewModel = enclyViewModel(), +) { + val state = remember { PinFlowState() } + val securityState by securityViewModel.uiState.collectAsStateWithLifecycle() + val flowActions = remember(state, securityViewModel) { + PinFlowActions( + verify = securityViewModel::verifyCurrentPin, + lockoutMillis = securityViewModel::pinLockoutRemainingMillis, + save = { pin -> securityViewModel.setDuressPin(pin) { result -> state.onDuressPinResult(result) } }, + close = { navController.popBackStack() }, + ) + } + + PinFlowScaffold( + state = state, + texts = DuressPinTexts, + actions = flowActions, + modifier = modifier, + ) { paddingValues -> + DuressPinOptions( + info = DuressPinInfo(turnedOff = securityState.duressPinTurnedOff, isSet = securityState.hasDuressPin), + actions = DuressPinActions( + onSet = state::startNew, + onRemove = { + securityViewModel.removeDuressPin { ok -> + state.message = if (ok) R.string.duress_pin_removed else R.string.duress_pin_failed + } + }, + ), + modifier = Modifier.padding(paddingValues), + ) + } +} + +private fun PinFlowState.onDuressPinResult(result: DuressPinChange) { + when (result) { + DuressPinChange.SET -> saved(R.string.duress_pin_saved_kept) + DuressPinChange.SET_NEW_VAULT -> saved(R.string.duress_pin_saved) + DuressPinChange.SAME_AS_PIN -> restartNew(R.string.duress_pin_same_as_pin) + DuressPinChange.IN_USE -> restartNew(R.string.pin_in_use) + DuressPinChange.FAILED -> restartNew(R.string.duress_pin_failed) + } +} + +/** What the options page shows besides the actions; [isSet]: a decoy vault exists, live or [turnedOff]. */ +private class DuressPinInfo(val turnedOff: Boolean, val isSet: Boolean) + +private class DuressPinActions(val onSet: () -> Unit, val onRemove: () -> Unit) + +@Composable +private fun DuressPinOptions(info: DuressPinInfo, actions: DuressPinActions, modifier: Modifier = Modifier) { + val spacing = EnclyTheme.spacing + var confirmingRemove by rememberSaveable { mutableStateOf(false) } + if (confirmingRemove) { + RemoveDuressPinDialog( + onConfirm = { + confirmingRemove = false + actions.onRemove() + }, + onDismiss = { confirmingRemove = false }, + ) + } + Column( + verticalArrangement = Arrangement.spacedBy(spacing.m), + modifier = modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(horizontal = spacing.gutter, vertical = spacing.s), + ) { + if (info.turnedOff) { + EnclyCallout(text = stringResource(R.string.duress_pin_turned_off), tone = CalloutTone.WARNING) + } + Text( + text = stringResource(R.string.duress_pin_intro), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, + ) + EnclyCallout(text = stringResource(R.string.duress_pin_real_vault)) + Text( + text = stringResource(R.string.duress_pin_limits), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = stringResource( + if (info.isSet && !info.turnedOff) R.string.duress_pin_status_on else R.string.duress_pin_status_off, + ), + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSurface, + ) + Column( + verticalArrangement = Arrangement.spacedBy(spacing.xxs), + modifier = Modifier.fillMaxWidth().padding(top = spacing.s), + ) { + EnclyButton( + text = stringResource(if (info.isSet) R.string.duress_pin_change else R.string.duress_pin_set), + onClick = actions.onSet, + ) + if (info.isSet) { + EnclyTextButton( + text = stringResource(R.string.duress_pin_remove), + onClick = { confirmingRemove = true }, + destructive = true, + modifier = Modifier.fillMaxWidth(), + ) + } + } + } +} + +/** Turning it off deletes the decoy vault; the real vault is not affected. */ +@Composable +private fun RemoveDuressPinDialog(onConfirm: () -> Unit, onDismiss: () -> Unit) { + EnclyDialog( + title = stringResource(R.string.duress_pin_remove_confirm_title), + text = stringResource(R.string.duress_pin_remove_confirm_body), + onDismissRequest = onDismiss, + confirm = DialogAction(stringResource(R.string.duress_pin_remove_confirm), onConfirm, destructive = true), + dismiss = DialogAction(stringResource(R.string.cancel), onDismiss), + ) +} diff --git a/app/src/main/java/com/pasich/encly/presentation/screen/settings/PinFlow.kt b/app/src/main/java/com/pasich/encly/presentation/screen/settings/PinFlow.kt new file mode 100644 index 00000000..76684dfe --- /dev/null +++ b/app/src/main/java/com/pasich/encly/presentation/screen/settings/PinFlow.kt @@ -0,0 +1,259 @@ +package com.pasich.encly.presentation.screen.settings + +import androidx.activity.compose.BackHandler +import androidx.annotation.StringRes +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHostState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableLongStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import com.pasich.encly.R +import com.pasich.encly.core.security.SensitiveDataCleaner +import com.pasich.encly.presentation.designsystem.EnclySnackbarHost +import com.pasich.encly.presentation.designsystem.EnclyTopBar +import com.pasich.encly.presentation.screen.pincode.PinBuffer +import com.pasich.encly.presentation.screen.pincode.PinEntry +import com.pasich.encly.presentation.screen.pincode.PinEntryActions +import com.pasich.encly.presentation.screen.pincode.PinEntryScaffold +import com.pasich.encly.presentation.screen.pincode.PinLockoutTicker +import com.pasich.encly.presentation.screen.pincode.lockoutSecondsLeft +import com.pasich.encly.presentation.screen.pincode.pinLockoutText +import kotlinx.coroutines.launch + +internal enum class PinStep { VERIFY, OPTIONS, NEW, CONFIRM } + +/** + * Settings pages that set a second PIN (the wipe PIN, the duress PIN) share one flow: the real + * PIN first, then an options page, then the new PIN typed twice. The PINs are CharArrays that + * are wiped once used, never Strings (see [PinBuffer]). + */ +internal class PinFlowState { + var step by mutableStateOf(PinStep.VERIFY) + + /** The new PIN typed at [PinStep.NEW], until the confirmation is compared with it. */ + private var firstPin: CharArray? = null + + val input = PinBuffer() + var errorText by mutableStateOf(null) + var lockoutSeconds by mutableLongStateOf(0L) + + /** Changes on every wrong entry, so the dots shake again. */ + var shakeKey by mutableIntStateOf(0) + + val snackbarHostState = SnackbarHostState() + + /** A finished action to announce on the options page. */ + var message by mutableStateOf(null) + + val keysEnabled: Boolean get() = !(step == PinStep.VERIFY && lockoutSeconds > 0L) + + fun typeDigit(digit: Int) { + if (keysEnabled) input.add(digit) + } + + /** The PIN of the current step is complete: check it, keep it, or compare it. */ + fun onPinComplete(actions: PinFlowActions) { + // The ViewModel wipes what it is given; everything else is wiped here. + val pin = input.take() + when (step) { + PinStep.VERIFY -> actions.verify(pin) { ok -> onCurrentPinChecked(ok, actions.lockoutMillis()) } + + PinStep.NEW -> { + firstPin?.let(SensitiveDataCleaner::clear) + firstPin = pin + errorText = null + step = PinStep.CONFIRM + } + + PinStep.CONFIRM -> confirm(pin, actions.save) + + PinStep.OPTIONS -> SensitiveDataCleaner.clear(pin) + } + } + + fun startNew() { + errorText = null + step = PinStep.NEW + } + + /** The new PIN was saved: announce [text] on the options page. */ + fun saved(@StringRes text: Int) { + message = text + step = PinStep.OPTIONS + } + + /** The new PIN was refused: ask for another, with [error]. */ + fun restartNew(@StringRes error: Int) { + errorText = error + shakeKey++ + step = PinStep.NEW + } + + /** Back from a PIN step to the options; false when there is nothing to go back to here. */ + fun back(): Boolean { + if (step != PinStep.NEW && step != PinStep.CONFIRM) return false + clear() + errorText = null + step = PinStep.OPTIONS + return true + } + + /** Typed or kept digits do not outlive the screen. */ + fun clear() { + input.clear() + firstPin?.let(SensitiveDataCleaner::clear) + firstPin = null + } + + private fun onCurrentPinChecked(ok: Boolean, lockoutMillis: Long) { + val lockout = lockoutSecondsLeft(lockoutMillis) + errorText = null + when { + ok -> step = PinStep.OPTIONS + + // A locked-out PIN is refused even when right: say so, not "wrong PIN". + lockout > 0L -> { + lockoutSeconds = lockout + shakeKey++ + } + + else -> { + errorText = R.string.pin_current_wrong + shakeKey++ + } + } + } + + private fun confirm(pin: CharArray, save: (CharArray) -> Unit) { + val first = firstPin + firstPin = null + val matches = first != null && pin.contentEquals(first) + first?.let(SensitiveDataCleaner::clear) + if (!matches) { + SensitiveDataCleaner.clear(pin) + restartNew(R.string.pin_mismatch_retry) + return + } + save(pin) + } +} + +/** What a PIN flow calls out to; [save] takes over the confirmed PIN (and wipes it). */ +internal class PinFlowActions( + val verify: (CharArray, (Boolean) -> Unit) -> Unit, + val lockoutMillis: () -> Long, + val save: (CharArray) -> Unit, + val close: () -> Unit, +) + +/** The strings of a PIN flow's steps. */ +internal class PinFlowTexts( + @param:StringRes val title: Int, + @param:StringRes val currentSubtitle: Int, + @param:StringRes val newTitle: Int, + @param:StringRes val newSubtitle: Int, + @param:StringRes val confirmTitle: Int, + @param:StringRes val confirmSubtitle: Int, +) + +/** + * The frame of a PIN flow: top bar, snackbar and the keypad steps. [options] draws the + * [PinStep.OPTIONS] page. + */ +@Composable +internal fun PinFlowScaffold( + state: PinFlowState, + texts: PinFlowTexts, + actions: PinFlowActions, + modifier: Modifier = Modifier, + options: @Composable (PaddingValues) -> Unit, +) { + BackHandler(enabled = state.step == PinStep.NEW || state.step == PinStep.CONFIRM) { state.back() } + PinLockoutTicker(state.lockoutSeconds > 0L, actions.lockoutMillis) { state.lockoutSeconds = it } + LaunchedEffect(state.input.length) { + if (state.input.isFull) state.onPinComplete(actions) + } + DisposableEffect(state) { onDispose { state.clear() } } + PinFlowMessageEffect(state.message, state.snackbarHostState, onShow = { state.message = null }) + + Scaffold( + containerColor = MaterialTheme.colorScheme.surface, + topBar = { EnclyTopBar(title = stringResource(texts.title), onBack = { if (!state.back()) actions.close() }) }, + snackbarHost = { EnclySnackbarHost(state.snackbarHostState) }, + modifier = modifier.fillMaxSize(), + ) { paddingValues -> + if (state.step == PinStep.OPTIONS) { + options(paddingValues) + } else { + PinFlowEntry( + text = pinFlowStepText(state, texts), + state = state, + modifier = Modifier.padding(paddingValues), + ) + } + } +} + +/** Shows [message] once as a snackbar; [onShow] clears it. */ +@Composable +private fun PinFlowMessageEffect(@StringRes message: Int?, snackbarHostState: SnackbarHostState, onShow: () -> Unit) { + val messageText = message?.let { stringResource(it) } + val currentOnShow by rememberUpdatedState(onShow) + // Clearing the message restarts this effect, which must not cancel the snackbar it opened. + val snackbarScope = rememberCoroutineScope() + LaunchedEffect(message, messageText) { + val text = messageText ?: return@LaunchedEffect + currentOnShow() + snackbarScope.launch { snackbarHostState.showSnackbar(text) } + } +} + +/** A PIN step's title and subtitle, and an error or lockout message. */ +private class PinStepText(@param:StringRes val title: Int, @param:StringRes val subtitle: Int, val message: String?) + +@Composable +private fun pinFlowStepText(state: PinFlowState, texts: PinFlowTexts): PinStepText { + val lockout = state.lockoutSeconds + .takeIf { it > 0L && state.step == PinStep.VERIFY } + ?.let { pinLockoutText(it) } + val error = state.errorText?.let { stringResource(it) } + return when (state.step) { + PinStep.VERIFY -> PinStepText(R.string.pin_change_current_title, texts.currentSubtitle, lockout ?: error) + PinStep.CONFIRM -> PinStepText(texts.confirmTitle, texts.confirmSubtitle, error) + else -> PinStepText(texts.newTitle, texts.newSubtitle, error) + } +} + +@Composable +private fun PinFlowEntry(text: PinStepText, state: PinFlowState, modifier: Modifier = Modifier) { + val message = text.message + PinEntryScaffold( + title = stringResource(text.title), + // An error or the lockout countdown takes the sub-heading slot, as on the lock screen. + subtitle = message ?: stringResource(text.subtitle), + subtitleIsError = message != null, + compact = true, + modifier = modifier, + ) { + PinEntry( + entered = state.input.length, + error = message != null && state.input.length == 0, + shakeKey = state.shakeKey, + enabled = state.keysEnabled, + actions = PinEntryActions(onDigit = state::typeDigit, onBackspace = state.input::deleteLast), + ) + } +} diff --git a/app/src/main/java/com/pasich/encly/presentation/screen/settings/SecuritySections.kt b/app/src/main/java/com/pasich/encly/presentation/screen/settings/SecuritySections.kt index 5d91f750..94f717d9 100644 --- a/app/src/main/java/com/pasich/encly/presentation/screen/settings/SecuritySections.kt +++ b/app/src/main/java/com/pasich/encly/presentation/screen/settings/SecuritySections.kt @@ -3,7 +3,10 @@ package com.pasich.encly.presentation.screen.settings import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text @@ -12,6 +15,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import com.pasich.encly.R @@ -22,7 +26,10 @@ import com.pasich.encly.presentation.designsystem.EnclyGroupDivider import com.pasich.encly.presentation.designsystem.EnclyHoldToConfirmButton import com.pasich.encly.presentation.designsystem.EnclyIcons import com.pasich.encly.presentation.designsystem.EnclyListRow +import com.pasich.encly.presentation.designsystem.EnclyNavigationRow +import com.pasich.encly.presentation.designsystem.EnclySwitchRow import com.pasich.encly.presentation.designsystem.SectionOverline +import com.pasich.encly.presentation.viewmodel.BackupAction import com.pasich.encly.ui.theme.EnclyTheme /** Erasing everything is held for five seconds, then re-authenticated. */ @@ -119,3 +126,111 @@ internal fun EraseSection(busy: Boolean, onErase: () -> Unit, modifier: Modifier } } } + +/** + * "Wipe PIN": always the same row, set or not (only the wipe PIN itself could tell). After a PIN + * key reset it says that any wipe PIN was turned off. + */ +@Composable +internal fun WipePinRow(turnedOff: Boolean, onClick: () -> Unit) { + EnclyNavigationRow( + title = stringResource(R.string.wipe_pin_title), + supporting = stringResource(if (turnedOff) R.string.wipe_pin_row_turned_off else R.string.wipe_pin_row_desc), + icon = EnclyIcons.Trash, + onClick = onClick, + modifier = Modifier.padding(horizontal = EnclyTheme.spacing.s), + ) +} + +/** "Duress PIN": opens a separate vault; after a PIN key reset it says that it was turned off. */ +@Composable +internal fun DuressPinRow(turnedOff: Boolean, onClick: () -> Unit) { + EnclyNavigationRow( + title = stringResource(R.string.duress_pin_title), + supporting = stringResource( + if (turnedOff) R.string.duress_pin_row_turned_off else R.string.duress_pin_row_desc, + ), + icon = EnclyIcons.Shield, + onClick = onClick, + modifier = Modifier.padding(horizontal = EnclyTheme.spacing.s), + ) +} + +/** + * The recovery phrase: once set up it is a status (it is never shown again; a separate row + * replaces it with new words). + * Without one the row creates it, after re-authentication: a vault set up without one can + * otherwise only be wiped when the PIN is forgotten. + */ +@Composable +internal fun RecoveryPhraseRow(hasRecoveryPhrase: Boolean, busy: Boolean, onStart: (BackupAction) -> Unit) { + val title = stringResource(R.string.backup_needs_phrase_title) + val modifier = Modifier.padding(horizontal = EnclyTheme.spacing.s) + if (hasRecoveryPhrase) { + EnclyListRow( + title = title, + supporting = stringResource(R.string.security_recovery_set_desc), + icon = EnclyIcons.File, + modifier = modifier, + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(EnclyTheme.spacing.xxs), + ) { + Icon( + EnclyIcons.Check, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(EnclyTheme.spacing.iconXSmall), + ) + Text( + text = stringResource(R.string.security_recovery_status_set), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.primary, + ) + } + } + } else { + EnclyNavigationRow( + title = title, + supporting = stringResource(R.string.security_recovery_missing), + icon = EnclyIcons.File, + onClick = { if (!busy) onStart(BackupAction.CREATE_PHRASE) }, + modifier = modifier, + ) + } +} + +/** + * "Replace recovery phrase": re-authentication, a warning that backups made before keep needing + * the old words, then new words shown and checked like the first ones. + */ +@Composable +internal fun ReplacePhraseRow(busy: Boolean, onStart: (BackupAction) -> Unit) { + EnclyNavigationRow( + title = stringResource(R.string.security_recovery_replace_title), + supporting = stringResource(R.string.security_recovery_replace_desc), + icon = EnclyIcons.Restore, + onClick = { if (!busy) onStart(BackupAction.REPLACE_PHRASE) }, + modifier = Modifier.padding(horizontal = EnclyTheme.spacing.s), + ) +} + +/** Privacy options that are not about unlocking: strict keyboard privacy (off by default). */ +@Composable +internal fun PrivacySection(strictKeyboard: Boolean, onStrictKeyboard: (Boolean) -> Unit) { + SectionOverline( + text = stringResource(R.string.security_privacy_section), + modifier = Modifier.padding(top = EnclyTheme.spacing.s), + ) + EnclyGroup { + EnclySwitchRow( + title = stringResource(R.string.security_keyboard_strict_title), + supporting = stringResource(R.string.security_keyboard_strict_desc), + icon = EnclyIcons.Keyboard, + checked = strictKeyboard, + onCheckedChange = onStrictKeyboard, + modifier = Modifier.padding(horizontal = EnclyTheme.spacing.s), + ) + } +} diff --git a/app/src/main/java/com/pasich/encly/presentation/screen/settings/SecuritySettingsScreen.kt b/app/src/main/java/com/pasich/encly/presentation/screen/settings/SecuritySettingsScreen.kt index e5f99d87..b6696dea 100644 --- a/app/src/main/java/com/pasich/encly/presentation/screen/settings/SecuritySettingsScreen.kt +++ b/app/src/main/java/com/pasich/encly/presentation/screen/settings/SecuritySettingsScreen.kt @@ -4,13 +4,10 @@ import androidx.activity.compose.LocalActivity import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHostState @@ -21,12 +18,10 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.fragment.app.FragmentActivity -import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavHostController import com.pasich.encly.R @@ -37,7 +32,6 @@ import com.pasich.encly.core.security.BiometricStatus import com.pasich.encly.presentation.designsystem.EnclyGroup import com.pasich.encly.presentation.designsystem.EnclyGroupDivider import com.pasich.encly.presentation.designsystem.EnclyIcons -import com.pasich.encly.presentation.designsystem.EnclyListRow import com.pasich.encly.presentation.designsystem.EnclyNavigationRow import com.pasich.encly.presentation.designsystem.EnclySnackbarHost import com.pasich.encly.presentation.designsystem.EnclySwitchRow @@ -50,6 +44,7 @@ import com.pasich.encly.presentation.viewmodel.BackupAction import com.pasich.encly.presentation.viewmodel.BackupMessage import com.pasich.encly.presentation.viewmodel.BackupViewModel import com.pasich.encly.presentation.viewmodel.SecuritySettingsViewModel +import com.pasich.encly.presentation.viewmodel.enclyViewModel import com.pasich.encly.ui.theme.EnclyTheme import kotlinx.coroutines.launch @@ -57,8 +52,8 @@ import kotlinx.coroutines.launch fun SecuritySettingsScreen( navController: NavHostController, modifier: Modifier = Modifier, - securityViewModel: SecuritySettingsViewModel = hiltViewModel(), - vaultViewModel: BackupViewModel = hiltViewModel(), + securityViewModel: SecuritySettingsViewModel = enclyViewModel(), + vaultViewModel: BackupViewModel = enclyViewModel(), ) { val securityState by securityViewModel.uiState.collectAsStateWithLifecycle() val strictKeyboard by securityViewModel.strictKeyboard.collectAsStateWithLifecycle() @@ -99,8 +94,7 @@ fun SecuritySettingsScreen( options = SecurityOptions(strictKeyboard = strictKeyboard, autoLock = autoLockDelay), vaultBusy = vaultState.busy, actions = SecurityActions( - onChangePin = { navController.navigate(NavRoutes.PinCodeConfig.name) }, - onWipePin = { navController.navigate(NavRoutes.WipePinRoute.name) }, + onOpen = { route -> navController.navigate(route.name) }, onBiometric = { enabled -> if (activity != null) securityViewModel.toggleBiometric(activity, enabled) }, @@ -120,8 +114,7 @@ fun SecuritySettingsScreen( /** What the security page can start. */ private class SecurityActions( - val onChangePin: () -> Unit, - val onWipePin: () -> Unit, + val onOpen: (NavRoutes) -> Unit, val onBiometric: (Boolean) -> Unit, val onVaultAction: (BackupAction) -> Unit, val onStrictKeyboard: (Boolean) -> Unit, @@ -146,53 +139,67 @@ private fun SecurityContent( .verticalScroll(rememberScrollState()) .padding(horizontal = EnclyTheme.spacing.gutter, vertical = EnclyTheme.spacing.s), ) { - EncryptionStatusCard( - title = stringResource(R.string.security_encryption_active), - text = stringResource( - if (securityState.isUserCreatedSeedKey) { - R.string.security_encryption_seed_plain - } else { - R.string.security_encryption_pin_plain - }, - ), - ) - SectionOverline( - text = stringResource(R.string.auth_title), - modifier = Modifier.padding(top = EnclyTheme.spacing.s), + SecurityRows(securityState, options, vaultBusy, actions) + } +} + +@Composable +private fun SecurityRows( + securityState: SecuritySettingsViewModel.SecuritySettingsUiState, + options: SecurityOptions, + vaultBusy: Boolean, + actions: SecurityActions, +) { + EncryptionStatusCard( + title = stringResource(R.string.security_encryption_active), + text = stringResource( + if (securityState.isUserCreatedSeedKey) { + R.string.security_encryption_seed_plain + } else { + R.string.security_encryption_pin_plain + }, + ), + ) + SectionOverline( + text = stringResource(R.string.auth_title), + modifier = Modifier.padding(top = EnclyTheme.spacing.s), + ) + EnclyGroup { + EnclyNavigationRow( + title = stringResource(R.string.auth_method_pin_title), + supporting = stringResource(R.string.auth_method_pin_desc), + icon = EnclyIcons.Key, + onClick = { actions.onOpen(NavRoutes.PinCodeConfig) }, + modifier = Modifier.padding(horizontal = EnclyTheme.spacing.s), ) - EnclyGroup { - EnclyNavigationRow( - title = stringResource(R.string.auth_method_pin_title), - supporting = stringResource(R.string.auth_method_pin_desc), - icon = EnclyIcons.Key, - onClick = actions.onChangePin, - modifier = Modifier.padding(horizontal = EnclyTheme.spacing.s), - ) - if (securityState.authType != AuthType.NONE) { - EnclyGroupDivider() - BiometricSetting(securityState, actions.onBiometric) - } - if (securityState.hasPinSlot) { - EnclyGroupDivider() - WipePinRow(turnedOff = securityState.wipePinTurnedOff, onClick = actions.onWipePin) - } + if (securityState.authType != AuthType.NONE) { EnclyGroupDivider() - AutoLockRow(current = options.autoLock, onSelect = actions.onAutoLock) + BiometricSetting(securityState, actions.onBiometric) + } + if (securityState.hasPinSlot) { EnclyGroupDivider() - RecoveryPhraseRow( - hasRecoveryPhrase = securityState.isUserCreatedSeedKey, - busy = vaultBusy, - onStart = actions.onVaultAction, - ) - if (securityState.isUserCreatedSeedKey) { - EnclyGroupDivider() - ReplacePhraseRow(busy = vaultBusy, onStart = actions.onVaultAction) - } + WipePinRow(turnedOff = securityState.wipePinTurnedOff, onClick = { actions.onOpen(NavRoutes.WipePinRoute) }) + EnclyGroupDivider() + DuressPinRow(turnedOff = securityState.duressPinTurnedOff, onClick = { + actions.onOpen(NavRoutes.DuressPinRoute) + }) + } + EnclyGroupDivider() + AutoLockRow(current = options.autoLock, onSelect = actions.onAutoLock) + EnclyGroupDivider() + RecoveryPhraseRow( + hasRecoveryPhrase = securityState.isUserCreatedSeedKey, + busy = vaultBusy, + onStart = actions.onVaultAction, + ) + if (securityState.isUserCreatedSeedKey) { + EnclyGroupDivider() + ReplacePhraseRow(busy = vaultBusy, onStart = actions.onVaultAction) } - PrivacySection(strictKeyboard = options.strictKeyboard, onStrictKeyboard = actions.onStrictKeyboard) - ProtectionSection() - EraseSection(busy = vaultBusy, onErase = { actions.onVaultAction(BackupAction.ERASE) }) } + PrivacySection(strictKeyboard = options.strictKeyboard, onStrictKeyboard = actions.onStrictKeyboard) + ProtectionSection() + EraseSection(busy = vaultBusy, onErase = { actions.onVaultAction(BackupAction.ERASE) }) } private class SecurityEffectActions( @@ -211,13 +218,14 @@ private fun SecurityScreenEffects( actions: SecurityEffectActions, ) { val context = LocalContext.current + val vaultMessageText = (vaultMessage as? BackupMessage.Text)?.let { stringResource(it.id) } // Clearing a message restarts its effect, which must not cancel the snackbar it just opened. val snackbarScope = rememberCoroutineScope() - LaunchedEffect(vaultMessage) { - val message = vaultMessage as? BackupMessage.Text ?: return@LaunchedEffect + LaunchedEffect(vaultMessage, vaultMessageText) { + val message = vaultMessageText ?: return@LaunchedEffect actions.onVaultMessageShown() - snackbarScope.launch { snackbarHostState.showSnackbar(context.getString(message.id)) } + snackbarScope.launch { snackbarHostState.showSnackbar(message) } } LaunchedEffect(vaultErased) { @@ -255,97 +263,3 @@ private fun BiometricSetting( modifier = Modifier.padding(horizontal = EnclyTheme.spacing.s), ) } - -/** - * "Wipe PIN": always the same row, set or not (only the wipe PIN itself could tell). After a PIN - * key reset it says that any wipe PIN was turned off. - */ -@Composable -private fun WipePinRow(turnedOff: Boolean, onClick: () -> Unit) { - EnclyNavigationRow( - title = stringResource(R.string.wipe_pin_title), - supporting = stringResource(if (turnedOff) R.string.wipe_pin_row_turned_off else R.string.wipe_pin_row_desc), - icon = EnclyIcons.Trash, - onClick = onClick, - modifier = Modifier.padding(horizontal = EnclyTheme.spacing.s), - ) -} - -/** - * The recovery phrase: once set up it is a status (it is never shown again; a separate row - * replaces it with new words). - * Without one the row creates it, after re-authentication: a vault set up without one can - * otherwise only be wiped when the PIN is forgotten. - */ -@Composable -private fun RecoveryPhraseRow(hasRecoveryPhrase: Boolean, busy: Boolean, onStart: (BackupAction) -> Unit) { - val title = stringResource(R.string.backup_needs_phrase_title) - val modifier = Modifier.padding(horizontal = EnclyTheme.spacing.s) - if (hasRecoveryPhrase) { - EnclyListRow( - title = title, - supporting = stringResource(R.string.security_recovery_set_desc), - icon = EnclyIcons.File, - modifier = modifier, - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(EnclyTheme.spacing.xxs), - ) { - Icon( - EnclyIcons.Check, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(EnclyTheme.spacing.iconXSmall), - ) - Text( - text = stringResource(R.string.security_recovery_status_set), - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.primary, - ) - } - } - } else { - EnclyNavigationRow( - title = title, - supporting = stringResource(R.string.security_recovery_missing), - icon = EnclyIcons.File, - onClick = { if (!busy) onStart(BackupAction.CREATE_PHRASE) }, - modifier = modifier, - ) - } -} - -/** - * "Replace recovery phrase": re-authentication, a warning that backups made before keep needing - * the old words, then new words shown and checked like the first ones. - */ -@Composable -private fun ReplacePhraseRow(busy: Boolean, onStart: (BackupAction) -> Unit) { - EnclyNavigationRow( - title = stringResource(R.string.security_recovery_replace_title), - supporting = stringResource(R.string.security_recovery_replace_desc), - icon = EnclyIcons.Restore, - onClick = { if (!busy) onStart(BackupAction.REPLACE_PHRASE) }, - modifier = Modifier.padding(horizontal = EnclyTheme.spacing.s), - ) -} - -/** Privacy options that are not about unlocking: strict keyboard privacy (off by default). */ -@Composable -private fun PrivacySection(strictKeyboard: Boolean, onStrictKeyboard: (Boolean) -> Unit) { - SectionOverline( - text = stringResource(R.string.security_privacy_section), - modifier = Modifier.padding(top = EnclyTheme.spacing.s), - ) - EnclyGroup { - EnclySwitchRow( - title = stringResource(R.string.security_keyboard_strict_title), - supporting = stringResource(R.string.security_keyboard_strict_desc), - icon = EnclyIcons.Keyboard, - checked = strictKeyboard, - onCheckedChange = onStrictKeyboard, - modifier = Modifier.padding(horizontal = EnclyTheme.spacing.s), - ) - } -} diff --git a/app/src/main/java/com/pasich/encly/presentation/screen/settings/SettingsScreen.kt b/app/src/main/java/com/pasich/encly/presentation/screen/settings/SettingsScreen.kt index ae3c7f68..48b08571 100644 --- a/app/src/main/java/com/pasich/encly/presentation/screen/settings/SettingsScreen.kt +++ b/app/src/main/java/com/pasich/encly/presentation/screen/settings/SettingsScreen.kt @@ -14,7 +14,6 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource -import androidx.hilt.navigation.compose.hiltViewModel import androidx.navigation.NavHostController import com.pasich.encly.R import com.pasich.encly.presentation.components.settings.SettingsBuilder @@ -25,13 +24,14 @@ import com.pasich.encly.presentation.designsystem.EnclyTopBar import com.pasich.encly.presentation.dialogs.LanguageDialog import com.pasich.encly.presentation.navigation.NavRoutes import com.pasich.encly.presentation.viewmodel.SettingsViewModel +import com.pasich.encly.presentation.viewmodel.enclyViewModel import com.pasich.encly.ui.theme.EnclyTheme @Composable fun SettingsScreen( navController: NavHostController?, modifier: Modifier = Modifier, - settingViewState: SettingsViewModel = hiltViewModel(), + settingViewState: SettingsViewModel = enclyViewModel(), securityDisable: Boolean = false, ) { val showTasks by settingViewState.showTasksFlow.collectAsState() @@ -48,6 +48,7 @@ fun SettingsScreen( onAppearance = { navController?.navigate(NavRoutes.AppearanceRoute.name) }, onSecurity = { navController?.navigate(NavRoutes.SecuritySettingsRoute.name) }, onBackup = { navController?.navigate(NavRoutes.BackupRoute.name) }, + onImportNotes = { navController?.navigate(NavRoutes.ImportNotesRoute.name) }, onAbout = { navController?.navigate(NavRoutes.AboutRoute.name) }, onFaq = { navController?.navigate(NavRoutes.FaqRoute.name) }, ), diff --git a/app/src/main/java/com/pasich/encly/presentation/screen/settings/WipePinScreen.kt b/app/src/main/java/com/pasich/encly/presentation/screen/settings/WipePinScreen.kt index 436e2fca..35bf71cc 100644 --- a/app/src/main/java/com/pasich/encly/presentation/screen/settings/WipePinScreen.kt +++ b/app/src/main/java/com/pasich/encly/presentation/screen/settings/WipePinScreen.kt @@ -1,8 +1,6 @@ package com.pasich.encly.presentation.screen.settings -import androidx.activity.compose.BackHandler import androidx.activity.compose.LocalActivity -import androidx.annotation.StringRes import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize @@ -11,244 +9,90 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Scaffold -import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf -import androidx.compose.runtime.mutableLongStateOf -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.rememberUpdatedState -import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.fragment.app.FragmentActivity -import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavHostController import com.pasich.encly.R -import com.pasich.encly.core.security.SensitiveDataCleaner import com.pasich.encly.core.security.WipePinChange import com.pasich.encly.presentation.designsystem.CalloutTone import com.pasich.encly.presentation.designsystem.EnclyButton import com.pasich.encly.presentation.designsystem.EnclyCallout -import com.pasich.encly.presentation.designsystem.EnclySnackbarHost import com.pasich.encly.presentation.designsystem.EnclyTextButton -import com.pasich.encly.presentation.designsystem.EnclyTopBar -import com.pasich.encly.presentation.screen.pincode.PinBuffer -import com.pasich.encly.presentation.screen.pincode.PinEntry -import com.pasich.encly.presentation.screen.pincode.PinEntryActions -import com.pasich.encly.presentation.screen.pincode.PinEntryScaffold -import com.pasich.encly.presentation.screen.pincode.PinLockoutTicker -import com.pasich.encly.presentation.screen.pincode.lockoutSecondsLeft -import com.pasich.encly.presentation.screen.pincode.pinLockoutText import com.pasich.encly.presentation.viewmodel.SecuritySettingsViewModel +import com.pasich.encly.presentation.viewmodel.enclyViewModel import com.pasich.encly.ui.theme.EnclyTheme -import kotlinx.coroutines.launch -private enum class WipePinStep { VERIFY, OPTIONS, NEW, CONFIRM } +private val WipePinTexts = PinFlowTexts( + title = R.string.wipe_pin_title, + currentSubtitle = R.string.wipe_pin_current_subtitle, + newTitle = R.string.wipe_pin_new_title, + newSubtitle = R.string.wipe_pin_new_subtitle, + confirmTitle = R.string.wipe_pin_confirm_title, + confirmSubtitle = R.string.wipe_pin_confirm_subtitle, +) /** * Settings → Security → Wipe PIN. The PIN comes first; then the page explains what a wipe PIN * does and offers to set one (replacing any earlier one) or turn it off. It never says whether - * one is set: only the wipe PIN itself could tell (see AuthenticationManager). The PINs are - * CharArrays that are wiped once used, never Strings (see [PinBuffer]). + * one is set: only the wipe PIN itself could tell (see AuthenticationManager). */ -private class WipePinState { - var step by mutableStateOf(WipePinStep.VERIFY) - - /** The wipe PIN typed at [WipePinStep.NEW], until the confirmation is compared with it. */ - private var firstPin: CharArray? = null - - val input = PinBuffer() - var errorText by mutableStateOf(null) - var lockoutSeconds by mutableLongStateOf(0L) - - /** Changes on every wrong entry, so the dots shake again. */ - var shakeKey by mutableIntStateOf(0) - - /** A finished action to announce on the options page. */ - var message by mutableStateOf(null) - - val keysEnabled: Boolean get() = !(step == WipePinStep.VERIFY && lockoutSeconds > 0L) - - fun typeDigit(digit: Int) { - if (keysEnabled) input.add(digit) - } - - /** The PIN of the current step is complete: check it, keep it, or compare it. */ - fun onPinComplete(viewModel: SecuritySettingsViewModel) { - // The ViewModel wipes what it is given; everything else is wiped here. - val pin = input.take() - when (step) { - WipePinStep.VERIFY -> viewModel.verifyCurrentPin(pin) { ok -> - onCurrentPinChecked(ok, viewModel.pinLockoutRemainingMillis()) - } - - WipePinStep.NEW -> { - firstPin?.let(SensitiveDataCleaner::clear) - firstPin = pin - errorText = null - step = WipePinStep.CONFIRM - } - - WipePinStep.CONFIRM -> confirm(pin, viewModel) - - WipePinStep.OPTIONS -> SensitiveDataCleaner.clear(pin) - } - } - - fun startNew() { - errorText = null - step = WipePinStep.NEW - } - - /** Back from a PIN step to the options; false when there is nothing to go back to here. */ - fun back(): Boolean { - if (step != WipePinStep.NEW && step != WipePinStep.CONFIRM) return false - clear() - errorText = null - step = WipePinStep.OPTIONS - return true - } - - /** Typed or kept digits do not outlive the screen. */ - fun clear() { - input.clear() - firstPin?.let(SensitiveDataCleaner::clear) - firstPin = null - } - - private fun onCurrentPinChecked(ok: Boolean, lockoutMillis: Long) { - val lockout = lockoutSecondsLeft(lockoutMillis) - errorText = null - when { - ok -> step = WipePinStep.OPTIONS - - // A locked-out PIN is refused even when right: say so, not "wrong PIN". - lockout > 0L -> { - lockoutSeconds = lockout - shakeKey++ - } - - else -> { - errorText = R.string.pin_current_wrong - shakeKey++ - } - } - } - - private fun confirm(pin: CharArray, viewModel: SecuritySettingsViewModel) { - val first = firstPin - firstPin = null - val matches = first != null && pin.contentEquals(first) - first?.let(SensitiveDataCleaner::clear) - if (!matches) { - SensitiveDataCleaner.clear(pin) - restartNew(R.string.pin_mismatch_retry) - return - } - viewModel.setWipePin(pin) { result -> - when (result) { - WipePinChange.SET -> { - message = R.string.wipe_pin_saved - step = WipePinStep.OPTIONS - } - - WipePinChange.SAME_AS_PIN -> restartNew(R.string.wipe_pin_same_as_pin) - - WipePinChange.FAILED -> restartNew(R.string.wipe_pin_failed) - } - } - } - - private fun restartNew(@StringRes error: Int) { - errorText = error - shakeKey++ - step = WipePinStep.NEW - } -} - @Composable fun WipePinScreen( navController: NavHostController, modifier: Modifier = Modifier, - securityViewModel: SecuritySettingsViewModel = hiltViewModel(), + securityViewModel: SecuritySettingsViewModel = enclyViewModel(), ) { - val state = remember { WipePinState() } + val state = remember { PinFlowState() } val securityState by securityViewModel.uiState.collectAsStateWithLifecycle() val activity = LocalActivity.current as? FragmentActivity - val snackbarHostState = remember { SnackbarHostState() } - - BackHandler(enabled = state.step == WipePinStep.NEW || state.step == WipePinStep.CONFIRM) { state.back() } - PinLockoutTicker(state.lockoutSeconds > 0L, securityViewModel::pinLockoutRemainingMillis) { - state.lockoutSeconds = it - } - LaunchedEffect(state.input.length) { - if (state.input.isFull) state.onPinComplete(securityViewModel) + val flowActions = remember(state, securityViewModel) { + PinFlowActions( + verify = securityViewModel::verifyCurrentPin, + lockoutMillis = securityViewModel::pinLockoutRemainingMillis, + save = { pin -> securityViewModel.setWipePin(pin) { result -> state.onWipePinResult(result) } }, + close = { navController.popBackStack() }, + ) } - DisposableEffect(state) { onDispose { state.clear() } } - WipePinMessageEffect(state.message, snackbarHostState, onShow = { state.message = null }) - Scaffold( - containerColor = MaterialTheme.colorScheme.surface, - topBar = { - EnclyTopBar( - title = stringResource(R.string.wipe_pin_title), - onBack = { if (!state.back()) navController.popBackStack() }, - ) - }, - snackbarHost = { EnclySnackbarHost(snackbarHostState) }, - modifier = modifier.fillMaxSize(), + PinFlowScaffold( + state = state, + texts = WipePinTexts, + actions = flowActions, + modifier = modifier, ) { paddingValues -> - if (state.step == WipePinStep.OPTIONS) { - WipePinOptions( - info = WipePinInfo( - turnedOff = securityState.wipePinTurnedOff, - biometricOn = securityState.biometricEnable, - ), - actions = WipePinActions( - onSet = state::startNew, - onRemove = { - securityViewModel.removeWipePin { ok -> - state.message = if (ok) R.string.wipe_pin_removed else R.string.wipe_pin_failed - } - }, - onBiometricOff = { - if (activity != null) securityViewModel.toggleBiometric(activity, enable = false) - }, - ), - modifier = Modifier.padding(paddingValues), - ) - } else { - WipePinEntry( - text = wipePinStepText(state), - entered = state.input.length, - entry = WipePinEntryState(enabled = state.keysEnabled, shakeKey = state.shakeKey), - actions = PinEntryActions(onDigit = state::typeDigit, onBackspace = state.input::deleteLast), - modifier = Modifier.padding(paddingValues), - ) - } + WipePinOptions( + info = WipePinInfo( + turnedOff = securityState.wipePinTurnedOff, + biometricOn = securityState.biometricEnable, + ), + actions = WipePinActions( + onSet = state::startNew, + onRemove = { + securityViewModel.removeWipePin { ok -> + state.message = if (ok) R.string.wipe_pin_removed else R.string.wipe_pin_failed + } + }, + onBiometricOff = { + if (activity != null) securityViewModel.toggleBiometric(activity, enable = false) + }, + ), + modifier = Modifier.padding(paddingValues), + ) } } -/** Shows [message] once as a snackbar; [onShow] clears it. */ -@Composable -private fun WipePinMessageEffect(@StringRes message: Int?, snackbarHostState: SnackbarHostState, onShow: () -> Unit) { - val context = LocalContext.current - val currentOnShow by rememberUpdatedState(onShow) - // Clearing the message restarts this effect, which must not cancel the snackbar it opened. - val snackbarScope = rememberCoroutineScope() - LaunchedEffect(message) { - val id = message ?: return@LaunchedEffect - currentOnShow() - snackbarScope.launch { snackbarHostState.showSnackbar(context.getString(id)) } +private fun PinFlowState.onWipePinResult(result: WipePinChange) { + when (result) { + WipePinChange.SET -> saved(R.string.wipe_pin_saved) + WipePinChange.SAME_AS_PIN -> restartNew(R.string.wipe_pin_same_as_pin) + WipePinChange.IN_USE -> restartNew(R.string.pin_in_use) + WipePinChange.FAILED -> restartNew(R.string.wipe_pin_failed) } } @@ -305,62 +149,3 @@ private fun WipePinOptions(info: WipePinInfo, actions: WipePinActions, modifier: } } } - -/** A PIN step's title and subtitle, and an error or lockout message. */ -private class WipePinStepText(@param:StringRes val title: Int, @param:StringRes val subtitle: Int, val message: String?) - -/** How the keypad of a PIN step behaves: off during a lockout, and the shake. */ -private class WipePinEntryState(val enabled: Boolean, val shakeKey: Int) - -@Composable -private fun wipePinStepText(state: WipePinState): WipePinStepText { - val lockout = state.lockoutSeconds - .takeIf { it > 0L && state.step == WipePinStep.VERIFY } - ?.let { pinLockoutText(it) } - return when (state.step) { - WipePinStep.VERIFY -> WipePinStepText( - title = R.string.pin_change_current_title, - subtitle = R.string.wipe_pin_current_subtitle, - message = lockout ?: state.errorText?.let { stringResource(it) }, - ) - - WipePinStep.CONFIRM -> WipePinStepText( - title = R.string.wipe_pin_confirm_title, - subtitle = R.string.wipe_pin_confirm_subtitle, - message = state.errorText?.let { stringResource(it) }, - ) - - else -> WipePinStepText( - title = R.string.wipe_pin_new_title, - subtitle = R.string.wipe_pin_new_subtitle, - message = state.errorText?.let { stringResource(it) }, - ) - } -} - -@Composable -private fun WipePinEntry( - text: WipePinStepText, - entered: Int, - entry: WipePinEntryState, - actions: PinEntryActions, - modifier: Modifier = Modifier, -) { - val message = text.message - PinEntryScaffold( - title = stringResource(text.title), - // An error or the lockout countdown takes the sub-heading slot, as on the lock screen. - subtitle = message ?: stringResource(text.subtitle), - subtitleIsError = message != null, - compact = true, - modifier = modifier, - ) { - PinEntry( - entered = entered, - error = message != null && entered == 0, - shakeKey = entry.shakeKey, - enabled = entry.enabled, - actions = actions, - ) - } -} diff --git a/app/src/main/java/com/pasich/encly/presentation/screen/trash/TrashNotesList.kt b/app/src/main/java/com/pasich/encly/presentation/screen/trash/TrashNotesList.kt index 71ad8e14..28e31d65 100644 --- a/app/src/main/java/com/pasich/encly/presentation/screen/trash/TrashNotesList.kt +++ b/app/src/main/java/com/pasich/encly/presentation/screen/trash/TrashNotesList.kt @@ -17,7 +17,6 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource -import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.pasich.encly.R import com.pasich.encly.core.common.LoadState @@ -28,6 +27,7 @@ import com.pasich.encly.presentation.designsystem.EnclyIcons import com.pasich.encly.presentation.designsystem.NoteSkeleton import com.pasich.encly.presentation.viewmodel.TrashListEvent import com.pasich.encly.presentation.viewmodel.TrashViewModel +import com.pasich.encly.presentation.viewmodel.enclyViewModel import com.pasich.encly.ui.theme.EnclyTheme private const val SKELETON_CARDS = 3 @@ -41,7 +41,7 @@ private const val SKELETON_CARDS = 3 fun TrashNotesList( onItemClick: (Int, Note) -> Unit, modifier: Modifier = Modifier, - trashViewModel: TrashViewModel = hiltViewModel(), + trashViewModel: TrashViewModel = enclyViewModel(), ) { val listScrollState = rememberLazyListState() val state by trashViewModel.state.collectAsStateWithLifecycle() diff --git a/app/src/main/java/com/pasich/encly/presentation/screen/trash/TrashScreen.kt b/app/src/main/java/com/pasich/encly/presentation/screen/trash/TrashScreen.kt index a125c85a..040f7be6 100644 --- a/app/src/main/java/com/pasich/encly/presentation/screen/trash/TrashScreen.kt +++ b/app/src/main/java/com/pasich/encly/presentation/screen/trash/TrashScreen.kt @@ -16,7 +16,6 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource -import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavHostController import com.pasich.encly.R @@ -28,6 +27,7 @@ import com.pasich.encly.presentation.dialogs.ConfirmDialog import com.pasich.encly.presentation.navigation.NavRoutes import com.pasich.encly.presentation.viewmodel.TrashListEvent import com.pasich.encly.presentation.viewmodel.TrashViewModel +import com.pasich.encly.presentation.viewmodel.enclyViewModel enum class DialogTrashAction { DELETE_PERMANENTLY, @@ -39,7 +39,7 @@ enum class DialogTrashAction { fun TrashScreen( navController: NavHostController, modifier: Modifier = Modifier, - trashViewModel: TrashViewModel = hiltViewModel(), + trashViewModel: TrashViewModel = enclyViewModel(), ) { val state by trashViewModel.state.collectAsStateWithLifecycle() var isDialogVisible by remember { mutableStateOf(DialogTrashAction.DISABLE) } diff --git a/app/src/main/java/com/pasich/encly/presentation/viewmodel/BackupViewModel.kt b/app/src/main/java/com/pasich/encly/presentation/viewmodel/BackupViewModel.kt index a6b84787..ffad0ec0 100644 --- a/app/src/main/java/com/pasich/encly/presentation/viewmodel/BackupViewModel.kt +++ b/app/src/main/java/com/pasich/encly/presentation/viewmodel/BackupViewModel.kt @@ -108,8 +108,13 @@ class BackupViewModel @Inject constructor( if (state.step != BackupStep.ConfirmErase) return dropPending() state.launchBusy { - withContext(Dispatchers.IO) { securityManager.wipeAndReset() } - state.ui.update { it.copy(step = BackupStep.Idle, erased = true) } + val erased = withContext(Dispatchers.IO) { securityManager.wipeAndReset() } + // A refused erase (see SecurityManager.wipeAndReset) must not be announced as done. + if (erased) { + state.ui.update { it.copy(step = BackupStep.Idle, erased = true) } + } else { + state.finish(BackupMessage.Text(R.string.security_erase_failed)) + } } } diff --git a/app/src/main/java/com/pasich/encly/presentation/viewmodel/EditNoteViewModel.kt b/app/src/main/java/com/pasich/encly/presentation/viewmodel/EditNoteViewModel.kt index 58b43d1a..42467b0a 100644 --- a/app/src/main/java/com/pasich/encly/presentation/viewmodel/EditNoteViewModel.kt +++ b/app/src/main/java/com/pasich/encly/presentation/viewmodel/EditNoteViewModel.kt @@ -10,6 +10,7 @@ import com.pasich.encly.core.security.VaultLockEvents import com.pasich.encly.domain.model.FontStyleType import com.pasich.encly.domain.model.ItemListBlock import com.pasich.encly.domain.model.LinkDataBlock +import com.pasich.encly.domain.model.NoteLineSpacing import com.pasich.encly.domain.repository.NotesRepository import com.pasich.encly.domain.repository.SettingsRepository import com.pasich.encly.domain.usecase.note.UpdateNoteTrashStatusUseCase @@ -23,21 +24,29 @@ import com.pasich.encly.presentation.editor.persistence.NotePersistence import com.pasich.encly.presentation.editor.persistence.SaveStatusNote import com.pasich.encly.presentation.editor.state.BlockEditorState import com.pasich.encly.presentation.editor.state.BlockRemoveAction +import com.pasich.encly.presentation.editor.state.FindState import com.pasich.encly.presentation.editor.state.FocusRequest import com.pasich.encly.presentation.editor.state.LineBreak import com.pasich.encly.presentation.editor.state.Shortcut +import com.pasich.encly.presentation.editor.state.SlashCommand +import com.pasich.encly.presentation.editor.state.SlashPicker import com.pasich.encly.presentation.editor.state.addBlockAfter import com.pasich.encly.presentation.editor.state.addBlockToEnd import com.pasich.encly.presentation.editor.state.applyShortcut +import com.pasich.encly.presentation.editor.state.applySlash +import com.pasich.encly.presentation.editor.state.applyTemplate import com.pasich.encly.presentation.editor.state.applyTool import com.pasich.encly.presentation.editor.state.backspaceAtStart import com.pasich.encly.presentation.editor.state.breakLine import com.pasich.encly.presentation.editor.state.canMoveInteracted import com.pasich.encly.presentation.editor.state.canRemoveInteracted +import com.pasich.encly.presentation.editor.state.duplicateBlockOf +import com.pasich.encly.presentation.editor.state.duplicateInteracted import com.pasich.encly.presentation.editor.state.focusFirstBlock import com.pasich.encly.presentation.editor.state.focusWorkingBlock import com.pasich.encly.presentation.editor.state.indexOf import com.pasich.encly.presentation.editor.state.interactedBlockPosition +import com.pasich.encly.presentation.editor.state.isBlankNote import com.pasich.encly.presentation.editor.state.listBackspaceAtStart import com.pasich.encly.presentation.editor.state.listLineBreak import com.pasich.encly.presentation.editor.state.listTypingStep @@ -48,12 +57,17 @@ import com.pasich.encly.presentation.editor.state.removeInteracted import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch @@ -128,6 +142,12 @@ constructor( /** Which block the editor UI should focus next. */ val focusRequests: Flow get() = editor.selection.requests + /** The slash picker (UI state; the paragraph it belongs to is named in its menu). */ + val slashPicker: SlashPicker get() = editor.slash + + /** The find bar: open or not, its query and matches. Never saved; cleared when the vault locks. */ + val find: StateFlow get() = editor.find.state + private val _lockEditor = MutableStateFlow(false) val lockEditor: StateFlow get() = _lockEditor @@ -150,6 +170,12 @@ constructor( initialValue = FontStyleType.DEFAULT, ) + val noteLineSpacing = settingsRepository.noteLineSpacingFlow.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5000), + initialValue = NoteLineSpacing.DEFAULT, + ) + val simpleEdit = settingsRepository.simpleEditFlow.stateIn( scope = viewModelScope, started = SharingStarted.WhileSubscribed(5000), @@ -166,11 +192,13 @@ constructor( addTag != 0L -> persistence.updateNote { it.copy(tagId = addTag) } } persistence.startAutosave(viewModelScope, editor.contentChanges) + keepFindInStep() rememberStoredId() // Session lock hook (security): a re-lock drops the note's plaintext at once, even // while the app is in the background and this screen is not composed. clearOnLock(lockEvents) { persistence.close() + // Also closes the find bar and the slash picker: no query or match survives a lock. editor.load(emptyList()) persistence.updateNote { it.copy(title = "") } } @@ -189,6 +217,16 @@ constructor( } } + /** While the find bar is open, its matches follow the text. Read-only: the blocks are only looked at. */ + @OptIn(FlowPreview::class) + private fun keepFindInStep() { + viewModelScope.launch { + editor.find.state.map { it.isOpen }.distinctUntilChanged().collectLatest { open -> + if (open) editor.contentChanges.debounce(FIND_REFRESH_MS).collect { editor.find.refresh(editor.blocks) } + } + } + } + private fun loadNote(id: Long, isCopy: Boolean) { viewModelScope.launch { val found = persistence.load(id, isCopy, editor::load) @@ -260,6 +298,10 @@ constructor( appScope.launch { settingsRepository.setFontSize(size) } } + fun updateNoteLineSpacing(value: NoteLineSpacing) { + appScope.launch { settingsRepository.setNoteLineSpacing(value) } + } + /** Locks or unlocks the editor. A note that could not be read, or one in the trash, stays locked. */ fun toggleLockEditor() { if (contentLoadFailed.value || isReadTrashOnly) return @@ -277,10 +319,17 @@ constructor( fun onBlockFocused(block: Block) = editor.selection.onFocused(block.id) /** The field of [block] (for a list: all of its items) lost focus. */ - fun onBlockFocusLost(block: Block) = editor.selection.onFocusLost(block.id) + fun onBlockFocusLost(block: Block) { + editor.selection.onFocusLost(block.id) + // Leaving the paragraph leaves its "/" as plain text. + if (editor.slash.menu.value?.blockId == block.id) editor.slash.dismiss() + } /** The user works on [block] without its field taking focus (a list item, a block sheet). */ - fun onBlockInteraction(block: Block) = editor.selection.onInteraction(block.id) + fun onBlockInteraction(block: Block) { + editor.selection.onInteraction(block.id) + editor.slash.dismiss() + } /** * Adds a block of [blockType] after the focused block (or the last one the user touched). @@ -334,8 +383,15 @@ constructor( * A line break typed or pasted into the field of [block] (see breakLine). Returns the text the * field keeps, or null to keep the break as text: in simple editing, or a locked editor. */ - fun onLineBreak(block: Block, lineBreak: LineBreak): String? = - if (canEdit(block) && !simpleEdit.value) editor.breakLine(block.id, lineBreak) else null + fun onLineBreak(block: Block, lineBreak: LineBreak): String? { + if (!canEdit(block) || simpleEdit.value) return null + // Enter with the slash picker showing picks the highlighted command instead of splitting. + val picked = editor.slash.menu.value + ?.takeIf { it.blockId == block.id && lineBreak.isEnter && it.isShown } + ?.highlightedCommand + val pickedHere = picked != null && editor.applySlash(block.id, picked) + return if (pickedHere) "" else editor.breakLine(block.id, lineBreak) + } /** Backspace at the start of [block]'s field; false when it does nothing there. */ fun onBackspaceAtStart(block: Block): Boolean = canEdit(block) && editor.backspaceAtStart(block.id) @@ -344,6 +400,23 @@ constructor( fun onShortcut(block: Block, typed: String, shortcut: Shortcut): Boolean = canEdit(block) && !simpleEdit.value && editor.applyShortcut(block.id, typed, shortcut) + /** + * Paragraph [block] now reads [text]. [started]: a "/" was just typed into the empty + * paragraph, which opens the slash picker (never in Simple Edit or a read-only editor); + * otherwise an open picker follows the text. + */ + fun onSlashTyped(block: Block, text: String, started: Boolean) { + if (started && block is Block.TextBlock && canOfferSlash(block)) editor.slash.open(block.id) + editor.slash.onText(block.id, text) + } + + private fun canOfferSlash(block: Block): Boolean = canEdit(block) && !simpleEdit.value + + /** The slash picker's [command] chosen for paragraph [block]. */ + fun applySlash(block: Block, command: SlashCommand) { + if (canEdit(block) && !simpleEdit.value) editor.applySlash(block.id, command) + } + /** A lone web address pasted into the empty paragraph [block]: it becomes a link card. */ fun onLinkPasted(block: Block, pasted: String, link: LinkDataBlock): Boolean = canEdit(block) && !simpleEdit.value && editor.pasteLink(block.id, pasted, link) @@ -422,6 +495,56 @@ constructor( if (canEdit()) editor.removeInteracted() } + /** Duplicates the block the user works on, right after it. */ + fun duplicateInteractedBlock() { + if (canEdit()) editor.duplicateInteracted() + } + + /** Duplicates [block], right after it: TalkBack's and the block sheets' action. */ + fun duplicateBlock(block: Block) { + if (canEdit(block)) editor.duplicateBlockOf(block.id) + } + + // --- find in this note: read-only, so it works in a locked or trashed note too --------------- + + fun openFind() = editor.find.open() + + fun closeFind() = editor.find.close() + + fun onFindQueryChanged(query: String) = editor.find.search(query, editor.blocks) + + fun findNext() = editor.find.next() + + fun findPrevious() = editor.find.previous() + + // --- templates ------------------------------------------------------------------------- + + /** + * Whether to offer "Start from template": a brand-new note that is still completely empty, + * and not in Simple Edit (it never shows block tools). Following the text, so it goes away + * with the first character and comes back if undo makes the note blank again, but not once + * the note has been saved (its stored id is set): from then on it is an existing note. + */ + val templatesOffered: StateFlow = combine( + persistence.state.map { it.note.title.isEmpty() && it.note.id <= 0L }.distinctUntilChanged(), + editor.contentChanges.map { editor.isBlankNote() }.onStart { emit(editor.isBlankNote()) }, + simpleEdit, + _lockEditor, + ) { blankUnsaved, blankBody, simple, locked -> isNewNote && blankUnsaved && blankBody && !simple && !locked } + .distinctUntilChanged() + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(STOP_TIMEOUT_MS), false) + + /** Starts the blank note from [blocks] (a template's, see NoteTemplate.blocks). */ + fun applyTemplate(blocks: List) { + if (canEdit() && !simpleEdit.value) editor.applyTemplate(blocks) + } + /** Whether the block the user works on is the first, the last or in between. */ fun getMoveBlockState(): Int = editor.interactedBlockPosition() } + +/** The find bar refreshes its matches this long after the text stops changing. */ +private const val FIND_REFRESH_MS = 100L + +/** How long state shared with the UI is kept after the last collector leaves. */ +private const val STOP_TIMEOUT_MS = 5_000L diff --git a/app/src/main/java/com/pasich/encly/presentation/viewmodel/ImportNotesViewModel.kt b/app/src/main/java/com/pasich/encly/presentation/viewmodel/ImportNotesViewModel.kt new file mode 100644 index 00000000..8a5f04a6 --- /dev/null +++ b/app/src/main/java/com/pasich/encly/presentation/viewmodel/ImportNotesViewModel.kt @@ -0,0 +1,355 @@ +package com.pasich.encly.presentation.viewmodel + +import android.net.Uri +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.pasich.encly.core.backup.BackupException +import com.pasich.encly.core.di.IoDispatcher +import com.pasich.encly.core.security.SessionLockManager +import com.pasich.encly.data.backup.BackupImporter +import com.pasich.encly.data.backup.ImportMode +import com.pasich.encly.data.backup.TagMatch +import com.pasich.encly.data.backup.VaultDataStore +import com.pasich.encly.data.importer.Analysis +import com.pasich.encly.data.importer.ImportAnalyzer +import com.pasich.encly.data.importer.ImportError +import com.pasich.encly.data.importer.ImportException +import com.pasich.encly.data.importer.ImportFileSource +import com.pasich.encly.data.importer.ImportPlan +import com.pasich.encly.data.importer.ImportPreview +import com.pasich.encly.data.importer.ImportReader +import com.pasich.encly.data.importer.ImportReview +import com.pasich.encly.data.importer.ImportSource +import com.pasich.encly.data.importer.ReadResult +import com.pasich.encly.data.importer.ReviewNote +import com.pasich.encly.data.importer.SafImportFiles +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Job +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.io.IOException +import javax.inject.Inject + +/** Why an import ended without writing anything. */ +enum class ImportFailure { + ENCRYPTED_STANDARD_NOTES, + ENCRYPTED_NOTESNOOK, + UNSUPPORTED, + TOO_LARGE, + NOTHING_FOUND, + UNREADABLE, + NO_PICKER, + VAULT, + IMPORT, +} + +/** The counts shown when an import is over. [notImported]: entries left out (unreadable, unsupported, ...). */ +data class ImportResult(val imported: Int, val duplicates: Int, val notImported: Int, val tagsAdded: Int) + +enum class ReviewFilter { ALL, NEW, TRASH } + +/** + * The review list and what the user ticked. Only in the ViewModel's memory: never saved in + * instance state, so a killed process shows the start page instead of anyone's notes. + */ +data class ReviewState( + val rows: List, + val selected: Set, + val filter: ReviewFilter = ReviewFilter.ALL, + /** The row whose read-only text is open, and that text. */ + val expanded: String? = null, + val expandedText: String = "", +) { + val newCount: Int get() = rows.count { !it.existing } + + val visible: List + get() = when (filter) { + ReviewFilter.ALL -> rows + ReviewFilter.NEW -> rows.filter { !it.existing } + ReviewFilter.TRASH -> rows.filter { it.trashed } + } + + companion object { + /** Every new note ticked; a note that is already in the vault never is. */ + fun of(rows: List) = ReviewState(rows, rows.filter { !it.existing }.mapTo(HashSet()) { it.uid }) + } +} + +/** Where the Import notes page is. */ +sealed interface ImportStep { + /** The supported apps and the two pickers. */ + data object Start : ImportStep + + /** Reading the selection and recognising its format. */ + data object Reading : ImportStep + + /** Nothing is written until the user confirms. */ + data class Preview(val preview: ImportPreview, val review: ReviewState) : ImportStep + + /** Files of several apps were found: the user names the source. */ + data object ChooseSource : ImportStep + + data object Importing : ImportStep + + data class Done(val result: ImportResult) : ImportStep + + data class Failed(val failure: ImportFailure) : ImportStep +} + +/** + * Settings → Import notes. A selection is read into memory (never to disk), recognised, mapped + * and shown as counts; only [confirm] writes, through the backup MERGE import in one transaction + * on the vault that was open when the read started (a re-lock or vault switch fails instead of + * writing elsewhere). Everything decrypted-in-memory (the read files, the mapped notes) is + * dropped on cancel, when the import ends, and the moment the vault locks. + */ +@Suppress("TooManyFunctions", "ReturnCount") // The page's actions; each one is a guard (no plan, no step) and a change. +@HiltViewModel +class ImportNotesViewModel @Inject constructor( + private val files: SafImportFiles, + private val store: VaultDataStore, + private val sessionLockManager: SessionLockManager, + @param:IoDispatcher private val ioDispatcher: CoroutineDispatcher, +) : ViewModel() { + + private val _step = MutableStateFlow(ImportStep.Start) + val step: StateFlow = _step.asStateFlow() + + private var job: Job? = null + private var bound: VaultDataStore? = null + private var plan: ImportPlan? = null + private var pending: ReadResult? = null + + init { + viewModelScope.launch { + sessionLockManager.locked.collect { locked -> + if (locked) { + job?.cancel() + drop() + _step.value = ImportStep.Start + } + } + } + } + + /** The picker is another app: keep the vault open for it, as the backup import does. */ + fun onPickerLaunched() = sessionLockManager.allowSystemPicker() + + /** No app on this device handles the Storage Access Framework picker. */ + fun onPickerUnavailable() { + sessionLockManager.endSystemPicker() + _step.value = ImportStep.Failed(ImportFailure.NO_PICKER) + } + + fun onFilesPicked(uris: List) { + sessionLockManager.endSystemPicker() + if (uris.isNotEmpty()) begin(files.files(uris)) + } + + fun onFolderPicked(tree: Uri?) { + sessionLockManager.endSystemPicker() + if (tree != null) begin(files.tree(tree)) + } + + /** Starts reading [source]; also the entry point for tests with their own sources. */ + internal fun begin(source: ImportFileSource) { + if (_step.value is ImportStep.Reading || _step.value is ImportStep.Importing) return + drop() + val store = try { + store.bindToCurrentSession() + } catch (_: RuntimeException) { + _step.value = ImportStep.Failed(ImportFailure.VAULT) + return + } + bound = store + _step.value = ImportStep.Reading + job = viewModelScope.launch { settle(read(store, source)) } + } + + /** + * What a read or analysis produced. It is applied on the main thread only, once the job is + * known to be live, so a cancelled or replaced job can never leave its notes behind. + */ + private class Outcome(val step: ImportStep, val plan: ImportPlan? = null, val pending: ReadResult? = null) + + private suspend fun settle(outcome: Outcome) { + currentCoroutineContext().ensureActive() + if (outcome.step is ImportStep.Failed) { + drop() + } else { + plan = outcome.plan + pending = outcome.pending + } + _step.value = outcome.step + } + + private suspend fun read(store: VaultDataStore, source: ImportFileSource): Outcome = try { + withContext(ioDispatcher) { + val context = currentCoroutineContext() + val read = ImportReader().read(source) { context.ensureActive() } + show(ImportAnalyzer.analyze(read), read, store) + } + } catch (e: CancellationException) { + throw e + } catch (e: ImportException) { + failure(failureOf(e.error)) + } catch (_: IOException) { + failure(ImportFailure.UNREADABLE) + } catch (_: SecurityException) { + failure(ImportFailure.UNREADABLE) + } catch (_: OutOfMemoryError) { + failure(ImportFailure.TOO_LARGE) + } catch (_: RuntimeException) { + // The vault closed while reading, or the snapshot failed. The cause can quote content. + failure(ImportFailure.VAULT) + } + + private fun failure(failure: ImportFailure) = Outcome(ImportStep.Failed(failure)) + + /** Pure: nothing of the view model is touched here, as this runs off the main thread. */ + private suspend fun show(analysis: Analysis, read: ReadResult, store: VaultDataStore): Outcome = when (analysis) { + is Analysis.Ready -> { + val existing = store.snapshot().notes.mapTo(HashSet()) { it.uid } + val ready = analysis.plan.withExisting(existing) + Outcome(ImportStep.Preview(ready.preview, ReviewState.of(ready.rows)), plan = ready) + } + + // Kept in memory so the user's choice can be applied without reading again. + Analysis.Ambiguous -> Outcome(ImportStep.ChooseSource, pending = read) + + is Analysis.Encrypted -> failure( + if (analysis.source == ImportSource.NOTESNOOK) { + ImportFailure.ENCRYPTED_NOTESNOOK + } else { + ImportFailure.ENCRYPTED_STANDARD_NOTES + }, + ) + + is Analysis.Failed -> failure(failureOf(analysis.error)) + } + + /** The user named the source of an ambiguous selection; it still has to validate. */ + fun chooseSource(source: ImportSource) { + val read = pending ?: return + if (_step.value != ImportStep.ChooseSource) return + val store = bound ?: return + _step.value = ImportStep.Reading + job = viewModelScope.launch { + settle( + try { + withContext(ioDispatcher) { show(ImportAnalyzer.analyze(read, forced = source), read, store) } + } catch (e: CancellationException) { + throw e + } catch (e: ImportException) { + failure(failureOf(e.error)) + } catch (_: RuntimeException) { + failure(ImportFailure.VAULT) + }, + ) + } + } + + /** Imports the previewed notes: one transaction, all or nothing. */ + fun confirm() { + val ready = plan ?: return + val store = bound ?: return + val review = (_step.value as? ImportStep.Preview)?.review ?: return + val selected = review.selected + if (selected.isEmpty()) return + val preview = ready.preview + val payload = ImportReview.selection(ready.payload, selected) + _step.value = ImportStep.Importing + job = viewModelScope.launch { + _step.value = try { + val summary = withContext(ioDispatcher) { + BackupImporter.import(payload, ImportMode.MERGE, store, TagMatch.UID_OR_NAME) + } + drop() + ImportStep.Done( + ImportResult( + imported = summary.notesAdded, + duplicates = payload.notes.size - summary.notesAdded + review.rows.count { it.existing }, + notImported = preview.unsupported + preview.filesSkipped + preview.nestedArchives + + (review.newCount - selected.size), + tagsAdded = summary.tagsAdded, + ), + ) + } catch (e: CancellationException) { + throw e + } catch (_: BackupException) { + failed(ImportFailure.IMPORT) + } catch (_: RuntimeException) { + failed(ImportFailure.IMPORT) + } + } + } + + fun toggle(uid: String) = review { state -> + if (state.rows.firstOrNull { it.uid == uid }?.existing != false) { + state + } else { + state.copy(selected = if (uid in state.selected) state.selected - uid else state.selected + uid) + } + } + + /** Ticks every new note, or clears the selection if they are all ticked already. */ + fun toggleAll() = review { state -> + val new = state.rows.filter { !it.existing }.mapTo(HashSet()) { it.uid } + state.copy(selected = if (state.selected.containsAll(new)) emptySet() else new) + } + + fun setFilter(filter: ReviewFilter) = review { it.copy(filter = filter) } + + /** Opens (or closes) the read-only text of one note. */ + fun expand(uid: String) = review { state -> + if (state.expanded == uid) { + state.copy(expanded = null, expandedText = "") + } else { + val note = plan?.payload?.notes?.firstOrNull { it.uid == uid } + state.copy(expanded = uid, expandedText = note?.let(ImportReview::body).orEmpty()) + } + } + + private fun review(change: (ReviewState) -> ReviewState) { + _step.update { step -> if (step is ImportStep.Preview) step.copy(review = change(step.review)) else step } + } + + /** Back to the start page; a read in progress is stopped and nothing has been written. */ + fun cancel() { + job?.cancel() + drop() + _step.value = ImportStep.Start + } + + override fun onCleared() { + job?.cancel() + drop() + super.onCleared() + } + + private fun failed(failure: ImportFailure): ImportStep { + drop() + return ImportStep.Failed(failure) + } + + private fun drop() { + plan = null + pending = null + bound = null + } + + private fun failureOf(error: ImportError): ImportFailure = when (error) { + ImportError.TOO_LARGE -> ImportFailure.TOO_LARGE + ImportError.UNSUPPORTED -> ImportFailure.UNSUPPORTED + ImportError.NOTHING_FOUND -> ImportFailure.NOTHING_FOUND + ImportError.UNREADABLE -> ImportFailure.UNREADABLE + } +} diff --git a/app/src/main/java/com/pasich/encly/presentation/viewmodel/PreloadedViewModelOwner.kt b/app/src/main/java/com/pasich/encly/presentation/viewmodel/PreloadedViewModelOwner.kt new file mode 100644 index 00000000..e463734b --- /dev/null +++ b/app/src/main/java/com/pasich/encly/presentation/viewmodel/PreloadedViewModelOwner.kt @@ -0,0 +1,26 @@ +package com.pasich.encly.presentation.viewmodel + +import androidx.compose.runtime.Composable +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelStoreOwner +import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner +import androidx.lifecycle.viewmodel.compose.viewModel + +/** A screen-test owner whose ViewModels are already present in its store. */ +interface PreloadedViewModelOwner : ViewModelStoreOwner + +/** + * AndroidX Hilt 1.4 creates its Activity-bound factory before checking the owner store. Screen + * tests use a plain Activity and preloaded ViewModels, so they can read that store directly. + * App owners still use the regular Hilt factory. + */ +@Composable +inline fun enclyViewModel(): VM { + val owner = checkNotNull(LocalViewModelStoreOwner.current) { "No ViewModelStoreOwner provided" } + return if (owner is PreloadedViewModelOwner) { + viewModel(viewModelStoreOwner = owner) + } else { + hiltViewModel(viewModelStoreOwner = owner) + } +} diff --git a/app/src/main/java/com/pasich/encly/presentation/viewmodel/SecuritySettingsViewModel.kt b/app/src/main/java/com/pasich/encly/presentation/viewmodel/SecuritySettingsViewModel.kt index 6fb83537..a4968932 100644 --- a/app/src/main/java/com/pasich/encly/presentation/viewmodel/SecuritySettingsViewModel.kt +++ b/app/src/main/java/com/pasich/encly/presentation/viewmodel/SecuritySettingsViewModel.kt @@ -9,7 +9,9 @@ import com.pasich.encly.core.security.AuthType import com.pasich.encly.core.security.AutoLock import com.pasich.encly.core.security.AutoLockDelay import com.pasich.encly.core.security.BiometricStatus +import com.pasich.encly.core.security.DuressPinChange import com.pasich.encly.core.security.KeyboardPrivacy +import com.pasich.encly.core.security.PinChange import com.pasich.encly.core.security.SecurityManager import com.pasich.encly.core.security.SensitiveDataCleaner import com.pasich.encly.core.security.WipePinChange @@ -64,6 +66,8 @@ class SecuritySettingsViewModel @Inject constructor( biometricStatus = securityManager.biometricStatus(), hasPinSlot = authSettings.hasPinSlot, wipePinTurnedOff = authSettings.wipePinTurnedOff, + hasDuressPin = authSettings.hasDuressPin, + duressPinTurnedOff = authSettings.duressPinTurnedOff, loaded = true, ) } @@ -95,21 +99,24 @@ class SecuritySettingsViewModel @Inject constructor( } /** Makes [pin] the vault's new PIN; [pin] is wiped. */ - fun activationPinAuth(pin: CharArray, onResult: (Boolean) -> Unit = {}) { + fun activationPinAuth(pin: CharArray, onResult: (PinChange) -> Unit = {}) { viewModelScope.launch { - val ok = withContext(Dispatchers.Default) { + val result = withContext(Dispatchers.Default) { try { - securityManager.configurePin(pin) + securityManager.changePin(pin) } finally { SensitiveDataCleaner.clear(pin) } } - if (ok) { - _uiState.value = _uiState.value.copy(authType = AuthType.PIN) - } else { - _uiState.value = _uiState.value.copy(error = UiText.of(R.string.pin_update_failed)) + when (result) { + PinChange.SET -> _uiState.value = _uiState.value.copy(authType = AuthType.PIN) + + // The screen says why, next to the keypad. + PinChange.IN_USE -> Unit + + PinChange.FAILED -> _uiState.value = _uiState.value.copy(error = UiText.of(R.string.pin_update_failed)) } - onResult(ok) + onResult(result) } } @@ -134,6 +141,29 @@ class SecuritySettingsViewModel @Inject constructor( } } + /** + * Makes [pin] the duress PIN, replacing any earlier one; [pin] is wiped. Only reached after + * [verifyCurrentPin] in the same screen. + */ + fun setDuressPin(pin: CharArray, onResult: (DuressPinChange) -> Unit) { + viewModelScope.launch { + val result = withContext(Dispatchers.Default) { securityManager.configureDuressPin(pin) } + if (result == DuressPinChange.SET || result == DuressPinChange.SET_NEW_VAULT) { + _uiState.value = _uiState.value.copy(hasDuressPin = true, duressPinTurnedOff = false) + } + onResult(result) + } + } + + /** Turns the duress PIN off and deletes the decoy vault with its notes. */ + fun removeDuressPin(onResult: (Boolean) -> Unit) { + viewModelScope.launch { + val ok = withContext(Dispatchers.IO) { securityManager.removeDuressPin() } + if (ok) _uiState.value = _uiState.value.copy(hasDuressPin = false, duressPinTurnedOff = false) + onResult(ok) + } + } + /** * Enabling biometrics performs the auth-bound CryptoObject enrollment itself. * Disabling an existing biometric slot requires a fresh strong-biometric confirmation. @@ -185,6 +215,10 @@ class SecuritySettingsViewModel @Inject constructor( val hasPinSlot: Boolean = false, /** The PIN key was reset and any wipe PIN with it; Settings asks to set it again. */ val wipePinTurnedOff: Boolean = false, + /** The real vault has a duress PIN. */ + val hasDuressPin: Boolean = false, + /** The PIN key was reset and the duress PIN with it; setting it again keeps the notes. */ + val duressPinTurnedOff: Boolean = false, /** The first [refresh] finished; until then the page shows nothing rather than guesses. */ val loaded: Boolean = false, ) { diff --git a/app/src/main/java/com/pasich/encly/ui/theme/EnclySpacing.kt b/app/src/main/java/com/pasich/encly/ui/theme/EnclySpacing.kt index a7e90d35..afe2262d 100644 --- a/app/src/main/java/com/pasich/encly/ui/theme/EnclySpacing.kt +++ b/app/src/main/java/com/pasich/encly/ui/theme/EnclySpacing.kt @@ -67,7 +67,7 @@ data class EnclySpacing( val iconXSmall: Dp = 18.dp, /** An empty-state or lock tile. */ val tile: Dp = 64.dp, - /** The globe tile on an editor link card. */ + /** A small tile: an editor link card's globe, the encryption status icon, an import source's mark. */ val tileSmall: Dp = 40.dp, /** Width of the navigation drawer on tablets. */ val drawerWidth: Dp = 320.dp, diff --git a/app/src/main/java/com/pasich/encly/ui/theme/Theme.kt b/app/src/main/java/com/pasich/encly/ui/theme/Theme.kt index 13bfcc9c..eccfaa47 100644 --- a/app/src/main/java/com/pasich/encly/ui/theme/Theme.kt +++ b/app/src/main/java/com/pasich/encly/ui/theme/Theme.kt @@ -18,14 +18,14 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalView import androidx.core.graphics.drawable.toDrawable import androidx.core.view.WindowCompat -import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.pasich.encly.domain.model.ThemePalette import com.pasich.encly.domain.model.ThemeSettings import com.pasich.encly.presentation.viewmodel.ThemeViewModel +import com.pasich.encly.presentation.viewmodel.enclyViewModel @Composable -fun AppTheme(themeViewModel: ThemeViewModel = hiltViewModel(), content: @Composable () -> Unit) { +fun AppTheme(themeViewModel: ThemeViewModel = enclyViewModel(), content: @Composable () -> Unit) { val themeSettings by themeViewModel.themeSettingsFlow.collectAsStateWithLifecycle() val isDarkTheme = themeSettings.isDark(isSystemInDarkTheme()) diff --git a/app/src/main/java/com/pasich/encly/utils/DataFormater.kt b/app/src/main/java/com/pasich/encly/utils/DataFormater.kt index 0192326c..041b5395 100644 --- a/app/src/main/java/com/pasich/encly/utils/DataFormater.kt +++ b/app/src/main/java/com/pasich/encly/utils/DataFormater.kt @@ -2,7 +2,7 @@ package com.pasich.encly.utils import androidx.compose.runtime.Composable import androidx.compose.runtime.remember -import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.res.stringResource import com.pasich.encly.R import java.text.DateFormat @@ -15,7 +15,7 @@ import java.util.Locale * follows the language picked in Settings, on every API level. */ @Composable -fun currentLocale(): Locale = LocalConfiguration.current.locales[0] ?: Locale.getDefault() +fun currentLocale(): Locale = LocalResources.current.configuration.locales[0] ?: Locale.ENGLISH /** A short date-and-time format in the in-app language (e.g. "5/14/26, 3:04 PM"). */ @Composable diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index d7a68280..2f5132e5 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -148,6 +148,10 @@ Encly meldet einen beschädigten Tresor Textgröße + Zeilenabstand + Standard + Kompakt + Großzügig Redaktionell Modern Technisch @@ -552,6 +556,7 @@ Tastatur einblenden Tastatur ausblenden Alle Daten löschen? + Die Daten konnten nicht gelöscht werden. Nichts wurde geändert. Löschen Unteraufgaben Unteraufgabe @@ -570,4 +575,30 @@ %1$d von %2$d Unteraufgabe erledigt %1$d von %2$d Unteraufgaben erledigt + Block einfügen + In Notiz suchen + In dieser Notiz suchen + %1$d von %2$d + Keine Treffer + Nächster Treffer + Vorheriger Treffer + Suche schließen + Block duplizieren + Mit Vorlage beginnen + Leer + Besprechung + Tagebuch + Projektnotizen + Teilnehmer + Tagesordnung + Notizen + Aufgaben + Heute + Dankbarkeit + Morgen + Ziel + Aufgaben + Entscheidungen + Offene Fragen + Zu erledigen diff --git a/app/src/main/res/values-de/strings_import.xml b/app/src/main/res/values-de/strings_import.xml new file mode 100644 index 00000000..aa62e595 --- /dev/null +++ b/app/src/main/res/values-de/strings_import.xml @@ -0,0 +1,80 @@ + + + + Import aus anderen Apps + Notizen importieren + Aus Google Keep, Standard Notes, Joplin, Notesnook oder Markdown-Dateien + Hole Notizen aus einer anderen App. Wähle deren Export, Encly erkennt das Format selbst. Alles bleibt auf diesem Telefon, nichts wird hochgeladen. + Dateien wählen + Ordner wählen + Unterstützte Apps + So exportierst du + Nicht importiert + Google Keep + Google-Takeout-.zip (unverändert wählen, ohne zu entpacken) oder einzelne .json-Notizen + Wähle in Google Takeout nur Keep, erstelle den Export und lade die .zip herunter. Wähle dann diese .zip hier aus. + Angeheftet- und Archiv-Status, Farben und Anhänge. Nur das erste Label wird zum Tag. + Standard Notes + Entschlüsseltes Backup (.txt oder .json) oder die .zip eines entschlüsselten Downloads + Lade in Standard Notes ein entschlüsseltes Backup herunter, kein verschlüsseltes. Wähle dann die Datei oder die .zip hier aus. + Verschlüsselte Backups werden abgelehnt. Angeheftet- und Archiv-Status, Dateien und andere Elementtypen. Nur das erste Tag bleibt. + Joplin + JEX-Archiv (.jex), RAW-Ordner oder Markdown-Export + Wähle in Joplin Datei → Alles exportieren und dann JEX oder RAW. Wähle dann die Datei oder den Ordner hier aus. + Anhänge und der Aufgabenstatus (Aufgaben kommen als normale Notizen an). Eine Notiz erhält ihr erstes Tag, sonst den Namen ihres Notizbuchs. + Notesnook + Markdown-, Markdown-mit-Front-Matter- oder TXT-Export (.zip) + Exportiere deine Notizen in Notesnook als Markdown mit Front Matter, um Daten und Tags zu behalten. Wähle dann die .zip hier aus. Verschlüsselte Backups lassen sich nicht importieren. + Anhänge, Angeheftet- und Archiv-Status, Farben. Das erste Tag, sonst das Notizbuch, wird zum Tag. + Markdown und Text + .md- und .txt-Dateien oder ein ganzer Ordner, zum Beispiel aus Obsidian oder Markor + Wähle die Dateien oder den Ordner, der sie enthält. Der Dateiname wird zum Titel; einfaches Front Matter (Titel, Tags, Daten) wird gelesen. + Bilder, Anhänge, Plugins und Formatierung über Überschriften, Listen, Checklisten und Zitate hinaus. Der erste Ordner wird zum Tag. + Deine Dateien werden gelesen und erkannt … + Bereit zum Import + Erkannt: %1$s · %2$s + Die ausgewählten Notizen werden diesem Tresor hinzugefügt. Nichts wird ersetzt, und was schon da ist, wird übersprungen. + Notizen mit Checklisten: %1$d + Tags: %1$d + Notizen, die in den Papierkorb kommen: %1$d + Schon in diesem Tresor, übersprungen: %1$d + In deiner Auswahl wiederholt, übersprungen: %1$d + Ziel: dieser Tresor + Zurückbleibende Anhänge: %1$d + Angeheftete Notizen, die nicht angeheftet bleiben: %1$d + Archivierte Notizen, die als normale Notizen ankommen: %1$d + Nicht übernommene Notizfarben: %1$d + Verworfene zusätzliche Tags (eine Notiz hat ein Tag): %1$d + Aufgaben-Notizen, die als normale Notizen importiert werden: %1$d + Übersprungene verschlüsselte oder nicht unterstützte Einträge: %1$d + Nicht verwendbare Dateien: %1$d + Archive in Archiven, nicht geöffnet: %1$d + Wird importiert … + Importiert + %1$d hinzugefügt, %2$d als Duplikate übersprungen, %3$d nicht importiert. + Deine Originaldateien wurden nicht verändert. Lösche sie selbst, sobald du deine Notizen hier geprüft hast. + Aus welcher App stammen diese? + Die Dateien scheinen aus mehr als einer App zu stammen. Wähle die zu importierende; der Rest wird übersprungen. + Nichts wurde importiert + Dieses Standard-Notes-Backup ist verschlüsselt. Lade in Standard Notes ein entschlüsseltes Backup herunter und wähle dieses. + Das ist ein verschlüsseltes Notesnook-Backup. Exportiere deine Notizen in Notesnook stattdessen als Markdown und wähle die .zip. + Diese Dateien sind kein unterstützter Export. Sieh dir die unterstützten Apps an oder wähle Markdown- oder Textdateien. + Diese Auswahl ist größer, als Encly auf einmal importieren kann (64 MB Notizen, 2 GB insgesamt oder 20.000 Notizen). Importiere sie in kleineren Teilen. + In dieser Auswahl wurden keine Notizen gefunden. + Die gewählten Dateien konnten nicht gelesen werden. + Auf diesem Telefon gibt es keine Datei-Auswahl-App, die Encly nutzen kann. + Der Tresor wurde während des Imports geschlossen. Entsperre Encly und versuche es erneut. + Etwas ist schiefgelaufen, es wurde nichts importiert. Dein Tresor ist unverändert. + %1$d von %2$d ausgewählt + Alle auswählen + Keine auswählen + Alle + Nur neue + Im Papierkorb + Ohne Titel + Checkliste + Im Papierkorb + Schon im Tresor + %1$d importieren + Erneut wählen + diff --git a/app/src/main/res/values-de/strings_security.xml b/app/src/main/res/values-de/strings_security.xml index e11dcac7..95063cb2 100644 --- a/app/src/main/res/values-de/strings_security.xml +++ b/app/src/main/res/values-de/strings_security.xml @@ -59,6 +59,9 @@ Nach 30 Sekunden Nach 1 Minute Nach 2 Minuten + Nach 15 Minuten + Nach 30 Minuten + Bei längeren Zeiten bleibt der Tresor im Hintergrund offen. Wer Zugriff auf dein entsperrtes Telefon hat, kann deine Notizen sehen. So schützt Encly deine Notizen Ein zufälliger 256-Bit-Schlüssel Die ganze Datenbank ist mit einem Schlüssel verschlüsselt, der auf diesem Telefon erzeugt wurde. @@ -90,4 +93,30 @@ Lösch-PIN festgelegt Lösch-PIN ausgeschaltet Dein PIN-Schlüssel wurde zurückgesetzt, daher ist eine eventuelle Lösch-PIN ausgeschaltet. Lege sie neu fest, wenn du eine möchtest. + Diese PIN wird bereits verwendet. Wähle eine andere. + Zwangs-PIN + Eine zweite PIN, die einen getrennten Tresor mit eigenen Notizen öffnet + Ausgeschaltet, als dein PIN-Schlüssel zurückgesetzt wurde. Lege sie neu fest, wenn du eine möchtest. + Wenn dich jemand zwingt, Encly zu öffnen, gib statt deiner PIN die Zwangs-PIN ein. Encly öffnet einen getrennten, leeren Tresor, den du wie gewohnt nutzen kannst: Notizen, die du dort anlegst, bleiben darin erhalten. + Dein echter Tresor bleibt gesperrt und unberührt. Anders als die Lösch-PIN löscht die Zwangs-PIN nichts. Dein Fingerabdruck und deine Wiederherstellungsphrase öffnen immer den echten Tresor. + Sie schützt dich, wenn dich jemand auffordert, die App zu öffnen und ihm alles zu zeigen, nicht vor einer forensischen Untersuchung des Telefons. Während einer Sperre nach Fehlversuchen funktioniert auch die Zwangs-PIN nicht. + Eine Zwangs-PIN ist festgelegt. + Es ist keine Zwangs-PIN festgelegt. + Zwangs-PIN festlegen + Zwangs-PIN ändern + Zwangs-PIN ausschalten + Bestätige deine PIN, um die Zwangs-PIN einzurichten + Zwangs-PIN erstellen + 6 Ziffern, anders als deine PIN + Zwangs-PIN bestätigen + Gib die Zwangs-PIN noch einmal ein + Die Zwangs-PIN muss sich von deiner PIN unterscheiden + Die Zwangs-PIN konnte nicht gespeichert werden. Bitte versuche es erneut. + Zwangs-PIN festgelegt. + Zwangs-PIN geändert. Der Tarn-Tresor behält seine Notizen. + Zwangs-PIN ausgeschaltet + Dein PIN-Schlüssel wurde zurückgesetzt, daher funktioniert die Zwangs-PIN nicht mehr. Lege sie neu fest; der Tarn-Tresor behält seine Notizen. + Zwangs-PIN ausschalten? + Der Tarn-Tresor und die Notizen darin werden gelöscht. Dein echter Tresor bleibt unverändert. + Ausschalten diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index a7e69fcc..6c2ecfd5 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -148,6 +148,10 @@ Encly indica que la caja fuerte está dañada Tamaño del texto + Interlineado + Predeterminado + Compacto + Amplio Editorial Moderno Técnico @@ -557,6 +561,7 @@ Mostrar teclado Ocultar teclado ¿Borrar todos los datos? + No se pudieron borrar los datos. No se ha cambiado nada. Borrar Subtareas Subtarea @@ -576,4 +581,30 @@ %1$d de %2$d subtareas completadas %1$d de %2$d subtareas completadas + Insertar bloque + Buscar en la nota + Buscar en esta nota + %1$d de %2$d + Sin resultados + Siguiente resultado + Resultado anterior + Cerrar búsqueda + Duplicar bloque + Empezar con una plantilla + En blanco + Reunión + Diario + Notas de proyecto + Asistentes + Orden del día + Notas + Tareas pendientes + Hoy + Gratitud + Mañana + Objetivo + Tareas + Decisiones + Preguntas abiertas + Por hacer diff --git a/app/src/main/res/values-es/strings_import.xml b/app/src/main/res/values-es/strings_import.xml new file mode 100644 index 00000000..3313d0eb --- /dev/null +++ b/app/src/main/res/values-es/strings_import.xml @@ -0,0 +1,80 @@ + + + + Importar de otras apps + Importar notas + Desde Google Keep, Standard Notes, Joplin, Notesnook o archivos Markdown + Trae tus notas de otra app. Elige su exportación y Encly reconoce el formato solo. Todo se queda en este teléfono; no se sube nada. + Elegir archivos + Elegir carpeta + Apps compatibles + Cómo exportar + No se importa + Google Keep + .zip de Google Takeout (elígelo tal cual, sin descomprimir) o notas .json sueltas + En Google Takeout selecciona solo Keep, crea la exportación y descarga el .zip. Luego elige ese .zip aquí. + El estado de fijada y archivada, los colores y los adjuntos. Solo la primera etiqueta pasa a ser la etiqueta. + Standard Notes + Copia descifrada (.txt o .json) o el .zip de una descarga descifrada + En Standard Notes descarga una copia descifrada, no una cifrada. Luego elige el archivo o el .zip aquí. + Las copias cifradas se rechazan. El estado de fijada y archivada, los archivos y otros tipos de elementos. Solo se conserva la primera etiqueta. + Joplin + Archivo JEX (.jex), carpeta RAW o exportación Markdown + En Joplin elige Archivo → Exportar todo y JEX o RAW. Luego elige el archivo o la carpeta aquí. + Los adjuntos y el estado de las tareas (las tareas llegan como notas normales). Cada nota recibe su primera etiqueta o, si no tiene, el nombre de su libreta. + Notesnook + Exportación Markdown, Markdown con front matter o TXT (.zip) + En Notesnook exporta tus notas como Markdown con front matter para conservar fechas y etiquetas. Luego elige el .zip aquí. Las copias cifradas no se pueden importar. + Los adjuntos, el estado de fijada y archivada y los colores. La primera etiqueta, o si no hay, la libreta, pasa a ser la etiqueta. + Markdown y texto + Archivos .md y .txt o una carpeta entera, por ejemplo de Obsidian o Markor + Elige los archivos o la carpeta que los contiene. El nombre del archivo es el título; se lee el front matter sencillo (título, etiquetas, fechas). + Imágenes, adjuntos, plugins y formato más allá de títulos, listas, listas de tareas y citas. La primera carpeta pasa a ser la etiqueta. + Leyendo y reconociendo tus archivos… + Listo para importar + Detectado: %1$s · %2$s + Las notas marcadas se añaden a esta caja fuerte. No se reemplaza nada y lo que ya está aquí se omite. + Notas con listas de tareas: %1$d + Etiquetas: %1$d + Notas que van a la papelera: %1$d + Ya están en esta caja fuerte, omitidas: %1$d + Repetidas en tu selección, omitidas: %1$d + Destino: esta caja fuerte + Adjuntos que se quedan atrás: %1$d + Notas fijadas que no seguirán fijadas: %1$d + Notas archivadas que llegan como notas normales: %1$d + Colores de nota que no se conservan: %1$d + Etiquetas extra descartadas (una nota tiene una etiqueta): %1$d + Notas de tareas importadas como notas normales: %1$d + Elementos cifrados o no compatibles omitidos: %1$d + Archivos que no se pudieron usar: %1$d + Archivos dentro de archivos, sin abrir: %1$d + Importando… + Importado + %1$d añadidas, %2$d omitidas por duplicadas, %3$d sin importar. + Tus archivos originales no se han modificado. Bórralos tú cuando hayas revisado las notas aquí. + ¿De qué app son? + Los archivos parecen venir de más de una app. Elige la que quieres importar; el resto se omite. + No se importó nada + Esta copia de Standard Notes está cifrada. Descarga una copia descifrada en Standard Notes y elígela. + Es una copia cifrada de Notesnook. En Notesnook exporta las notas como Markdown y elige el .zip. + Estos archivos no son una exportación compatible. Mira las apps compatibles o elige archivos Markdown o de texto. + Esta selección es mayor de lo que Encly puede importar de una vez (64 MB de notas, 2 GB en total o 20 000 notas). Impórtala por partes. + No se encontraron notas en esta selección. + No se pudieron leer los archivos elegidos. + Este teléfono no tiene ninguna app selectora de archivos que Encly pueda usar. + La caja fuerte se cerró durante la importación. Desbloquea Encly e inténtalo de nuevo. + Algo salió mal y no se importó nada. Tu caja fuerte no ha cambiado. + %1$d de %2$d seleccionadas + Seleccionar todo + Quitar selección + Todas + Solo nuevas + En la papelera + Sin título + Lista de tareas + En la papelera + Ya en la caja fuerte + Importar %1$d + Elegir de nuevo + diff --git a/app/src/main/res/values-es/strings_security.xml b/app/src/main/res/values-es/strings_security.xml index 06ae6976..fb3a18b8 100644 --- a/app/src/main/res/values-es/strings_security.xml +++ b/app/src/main/res/values-es/strings_security.xml @@ -64,6 +64,9 @@ Tras 30 segundos Tras 1 minuto Tras 2 minutos + Tras 15 minutos + Tras 30 minutos + Un plazo mayor deja la bóveda abierta en segundo plano. Cualquiera con acceso al teléfono desbloqueado puede ver tus notas. Cómo protege Encly tus notas Una clave aleatoria de 256 bits Toda la base de datos está cifrada con una clave creada en este teléfono. @@ -95,4 +98,30 @@ PIN de borrado configurado PIN de borrado desactivado Se restableció la clave de tu PIN, así que el PIN de borrado, si tenías uno, se desactivó. Vuelve a configurarlo si lo quieres. + Este PIN ya está en uso. Elige otro distinto. + PIN de coacción + Un segundo PIN que abre una bóveda aparte con sus propias notas + Desactivado cuando se restableció la clave de tu PIN. Vuelve a configurarlo si quieres uno. + Si alguien te obliga a abrir Encly, escribe el PIN de coacción en lugar de tu PIN. Encly abre una bóveda aparte y vacía que puedes usar con normalidad: las notas que añadas allí se conservan. + Tu bóveda real sigue bloqueada e intacta. A diferencia del PIN de borrado, el PIN de coacción no borra nada. Tu huella y tu frase de recuperación siempre abren la bóveda real. + Te protege cuando alguien te pide que abras la app y se lo enseñes, no frente a un análisis forense del teléfono. Durante un bloqueo tras intentos fallidos, el PIN de coacción tampoco funciona. + Hay un PIN de coacción configurado. + No hay ningún PIN de coacción configurado. + Configurar PIN de coacción + Cambiar PIN de coacción + Desactivar PIN de coacción + Confirma tu PIN para configurar el PIN de coacción + Crea un PIN de coacción + 6 dígitos, distintos de tu PIN + Confirma el PIN de coacción + Introduce el PIN de coacción otra vez + El PIN de coacción debe ser distinto de tu PIN + No se pudo guardar el PIN de coacción. Inténtalo de nuevo. + PIN de coacción configurado. + PIN de coacción cambiado. La bóveda señuelo conserva sus notas. + PIN de coacción desactivado + La clave de tu PIN se restableció, así que el PIN de coacción dejó de funcionar. Configúralo de nuevo; la bóveda señuelo conserva sus notas. + ¿Desactivar el PIN de coacción? + La bóveda señuelo y las notas que contiene se eliminan. Tu bóveda real no se ve afectada. + Desactivar diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index ac7e5e51..4b7efc88 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -148,6 +148,10 @@ Encly indique que le coffre-fort est endommagé Taille du texte + Espacement des lignes + Par défaut + Compact + Aéré Éditorial Moderne Technique @@ -557,6 +561,7 @@ Afficher le clavier Masquer le clavier Effacer toutes les données ? + Impossible d\'effacer les données. Rien n\'a été modifié. Effacer Sous-tâches Sous-tâche @@ -576,4 +581,30 @@ %1$d sur %2$d sous-tâches terminées %1$d sur %2$d sous-tâches terminées + Insérer un bloc + Rechercher dans la note + Rechercher dans cette note + %1$d sur %2$d + Aucun résultat + Résultat suivant + Résultat précédent + Fermer la recherche + Dupliquer le bloc + Commencer avec un modèle + Vide + Réunion + Journal + Notes de projet + Participants + Ordre du jour + Notes + Actions à mener + Aujourd’hui + Gratitude + Demain + Objectif + Tâches + Décisions + Questions ouvertes + À faire diff --git a/app/src/main/res/values-fr/strings_import.xml b/app/src/main/res/values-fr/strings_import.xml new file mode 100644 index 00000000..da70ab91 --- /dev/null +++ b/app/src/main/res/values-fr/strings_import.xml @@ -0,0 +1,80 @@ + + + + Importer depuis d\'autres applications + Importer des notes + Depuis Google Keep, Standard Notes, Joplin, Notesnook ou des fichiers Markdown + Récupérez vos notes d\'une autre application. Choisissez son export et Encly reconnaît le format tout seul. Tout reste sur ce téléphone ; rien n\'est envoyé. + Choisir des fichiers + Choisir un dossier + Applications prises en charge + Comment exporter + Non importé + Google Keep + .zip Google Takeout (à choisir tel quel, sans le décompresser) ou notes .json isolées + Dans Google Takeout, sélectionnez seulement Keep, créez l\'export et téléchargez le .zip. Choisissez ensuite ce .zip ici. + L\'état épinglé et archivé, les couleurs et les pièces jointes. Seule la première étiquette devient le tag. + Standard Notes + Sauvegarde déchiffrée (.txt ou .json) ou .zip d\'un téléchargement déchiffré + Dans Standard Notes, téléchargez une sauvegarde déchiffrée, pas une chiffrée. Choisissez ensuite le fichier ou le .zip ici. + Les sauvegardes chiffrées sont refusées. L\'état épinglé et archivé, les fichiers et les autres types d\'éléments. Seul le premier tag est conservé. + Joplin + Archive JEX (.jex), dossier RAW ou export Markdown + Dans Joplin, choisissez Fichier → Tout exporter, puis JEX ou RAW. Choisissez ensuite le fichier ou le dossier ici. + Les pièces jointes et l\'état des tâches (les tâches arrivent comme des notes ordinaires). Une note reçoit son premier tag, sinon le nom de son carnet. + Notesnook + Export Markdown, Markdown avec front matter ou TXT (.zip) + Dans Notesnook, exportez vos notes en Markdown avec front matter pour garder dates et tags. Choisissez ensuite le .zip ici. Les sauvegardes chiffrées ne sont pas importables. + Les pièces jointes, l\'état épinglé et archivé, les couleurs. Le premier tag, sinon le carnet, devient le tag. + Markdown et texte + Fichiers .md et .txt ou un dossier entier, par exemple d\'Obsidian ou de Markor + Choisissez les fichiers ou le dossier qui les contient. Le nom du fichier devient le titre ; le front matter simple (titre, tags, dates) est lu. + Les images, pièces jointes, plugins et la mise en forme au-delà des titres, listes, listes de tâches et citations. Le premier dossier devient le tag. + Lecture et reconnaissance de vos fichiers… + Prêt à importer + Détecté : %1$s · %2$s + Les notes cochées sont ajoutées à ce coffre-fort. Rien n\'est remplacé et ce qui s\'y trouve déjà est ignoré. + Notes avec listes de tâches : %1$d + Tags : %1$d + Notes qui vont à la corbeille : %1$d + Déjà dans ce coffre-fort, ignorées : %1$d + Répétées dans votre sélection, ignorées : %1$d + Destination : ce coffre-fort + Pièces jointes laissées de côté : %1$d + Notes épinglées qui ne le resteront pas : %1$d + Notes archivées qui arrivent comme notes normales : %1$d + Couleurs de note non conservées : %1$d + Tags en trop abandonnés (une note a un tag) : %1$d + Notes de tâche importées comme notes ordinaires : %1$d + Éléments chiffrés ou non pris en charge ignorés : %1$d + Fichiers inutilisables : %1$d + Archives dans des archives, non ouvertes : %1$d + Importation… + Importé + %1$d ajoutées, %2$d ignorées comme doublons, %3$d non importées. + Vos fichiers d\'origine n\'ont pas été modifiés. Supprimez-les vous-même une fois vos notes vérifiées ici. + De quelle application viennent-ils ? + Les fichiers semblent venir de plusieurs applications. Choisissez celle à importer ; le reste est ignoré. + Rien n\'a été importé + Cette sauvegarde Standard Notes est chiffrée. Téléchargez une sauvegarde déchiffrée dans Standard Notes et choisissez-la. + C\'est une sauvegarde Notesnook chiffrée. Dans Notesnook, exportez plutôt vos notes en Markdown et choisissez le .zip. + Ces fichiers ne sont pas un export pris en charge. Consultez les applications prises en charge ou choisissez des fichiers Markdown ou texte. + Cette sélection dépasse ce qu\'Encly peut importer d\'un coup (64 Mo de notes, 2 Go au total ou 20 000 notes). Importez-la en plusieurs parties. + Aucune note n\'a été trouvée dans cette sélection. + Les fichiers choisis n\'ont pas pu être lus. + Ce téléphone n\'a pas d\'application de sélection de fichiers qu\'Encly puisse utiliser. + Le coffre-fort s\'est fermé pendant l\'importation. Déverrouillez Encly et réessayez. + Une erreur s\'est produite et rien n\'a été importé. Votre coffre-fort est inchangé. + %1$d sur %2$d sélectionnées + Tout sélectionner + Tout désélectionner + Toutes + Nouvelles seulement + Dans la corbeille + Sans titre + Liste de tâches + Dans la corbeille + Déjà dans le coffre-fort + Importer %1$d + Choisir à nouveau + diff --git a/app/src/main/res/values-fr/strings_security.xml b/app/src/main/res/values-fr/strings_security.xml index c28516ba..e603440f 100644 --- a/app/src/main/res/values-fr/strings_security.xml +++ b/app/src/main/res/values-fr/strings_security.xml @@ -64,6 +64,9 @@ Après 30 secondes Après 1 minute Après 2 minutes + Après 15 minutes + Après 30 minutes + Un délai plus long laisse le coffre ouvert en arrière-plan. Toute personne ayant accès à votre téléphone déverrouillé peut voir vos notes. Comment Encly protège vos notes Une clé aléatoire de 256 bits Toute la base est chiffrée avec une clé créée sur ce téléphone. @@ -95,4 +98,30 @@ PIN d\'effacement défini PIN d\'effacement désactivé La clé de votre PIN a été réinitialisée : un éventuel PIN d\'effacement a donc été désactivé. Définissez-le à nouveau si vous le souhaitez. + Ce PIN est déjà utilisé. Choisissez-en un autre. + PIN de contrainte + Un second PIN qui ouvre un coffre séparé avec ses propres notes + Désactivé lorsque la clé de votre PIN a été réinitialisée. Définissez-le à nouveau si vous en voulez un. + Si quelqu\'un vous force à ouvrir Encly, saisissez le PIN de contrainte au lieu de votre PIN. Encly ouvre un coffre séparé et vide que vous pouvez utiliser normalement : les notes que vous y ajoutez y restent. + Votre vrai coffre reste verrouillé et intact. Contrairement au PIN d\'effacement, le PIN de contrainte n\'efface rien. Votre empreinte et votre phrase de récupération ouvrent toujours le vrai coffre. + Il vous protège quand quelqu\'un vous demande d\'ouvrir l\'appli et de tout lui montrer, pas contre un examen forensique du téléphone. Pendant un verrouillage après des essais ratés, le PIN de contrainte ne fonctionne pas non plus. + Un PIN de contrainte est défini. + Aucun PIN de contrainte n\'est défini. + Définir le PIN de contrainte + Modifier le PIN de contrainte + Désactiver le PIN de contrainte + Confirmez votre PIN pour configurer le PIN de contrainte + Créer un PIN de contrainte + 6 chiffres, différents de votre PIN + Confirmer le PIN de contrainte + Saisissez à nouveau le PIN de contrainte + Le PIN de contrainte doit être différent de votre PIN + Impossible d\'enregistrer le PIN de contrainte. Veuillez réessayer. + PIN de contrainte défini. + PIN de contrainte modifié. Le coffre leurre conserve ses notes. + PIN de contrainte désactivé + La clé de votre PIN a été réinitialisée, le PIN de contrainte ne fonctionne donc plus. Définissez-le à nouveau ; le coffre leurre conserve ses notes. + Désactiver le PIN de contrainte ? + Le coffre leurre et les notes qu\'il contient sont supprimés. Votre vrai coffre n\'est pas affecté. + Désactiver diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 900166ce..36dd9e5e 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -148,6 +148,10 @@ Encly segnala che la cassaforte è danneggiata Dimensione del testo + Interlinea + Predefinita + Compatta + Ampia Editoriale Moderno Tecnico @@ -557,6 +561,7 @@ Mostra tastiera Nascondi tastiera Cancellare tutti i dati? + Impossibile cancellare i dati. Non è stato modificato nulla. Cancella Sottoattività Sottoattività @@ -576,4 +581,30 @@ %1$d di %2$d sottoattività completate %1$d di %2$d sottoattività completate + Inserisci blocco + Cerca nella nota + Cerca in questa nota + %1$d di %2$d + Nessun risultato + Risultato successivo + Risultato precedente + Chiudi ricerca + Duplica blocco + Inizia da un modello + Vuota + Riunione + Diario + Note di progetto + Partecipanti + Ordine del giorno + Note + Azioni da svolgere + Oggi + Gratitudine + Domani + Obiettivo + Attività + Decisioni + Domande aperte + Da fare diff --git a/app/src/main/res/values-it/strings_import.xml b/app/src/main/res/values-it/strings_import.xml new file mode 100644 index 00000000..8061f494 --- /dev/null +++ b/app/src/main/res/values-it/strings_import.xml @@ -0,0 +1,80 @@ + + + + Importa da altre app + Importa note + Da Google Keep, Standard Notes, Joplin, Notesnook o file Markdown + Porta qui le note di un\'altra app. Scegli la sua esportazione e Encly riconosce il formato da solo. Tutto resta su questo telefono; non viene caricato nulla. + Scegli file + Scegli cartella + App supportate + Come esportare + Non importato + Google Keep + .zip di Google Takeout (sceglilo così com\'è, senza decomprimerlo) o singole note .json + In Google Takeout seleziona solo Keep, crea l\'esportazione e scarica il .zip. Poi scegli quel .zip qui. + Stato fissata e archiviata, colori e allegati. Solo la prima etichetta diventa il tag. + Standard Notes + Backup decifrato (.txt o .json) o il .zip di un download decifrato + In Standard Notes scarica un backup decifrato, non uno cifrato. Poi scegli il file o il .zip qui. + I backup cifrati vengono rifiutati. Stato fissata e archiviata, file e altri tipi di elementi. Viene mantenuto solo il primo tag. + Joplin + Archivio JEX (.jex), cartella RAW o esportazione Markdown + In Joplin scegli File → Esporta tutto e poi JEX o RAW. Poi scegli il file o la cartella qui. + Allegati e stato delle attività (le attività arrivano come note normali). Una nota riceve il suo primo tag, altrimenti il nome del suo taccuino. + Notesnook + Esportazione Markdown, Markdown con front matter o TXT (.zip) + In Notesnook esporta le note come Markdown con front matter per mantenere date e tag. Poi scegli il .zip qui. I backup cifrati non si possono importare. + Allegati, stato fissata e archiviata, colori. Il primo tag, altrimenti il taccuino, diventa il tag. + Markdown e testo + File .md e .txt o un\'intera cartella, per esempio da Obsidian o Markor + Scegli i file o la cartella che li contiene. Il nome del file diventa il titolo; il front matter semplice (titolo, tag, date) viene letto. + Immagini, allegati, plugin e formattazione oltre a titoli, elenchi, checklist e citazioni. La prima cartella diventa il tag. + Lettura e riconoscimento dei file… + Pronto per l\'importazione + Rilevato: %1$s · %2$s + Le note selezionate vengono aggiunte a questa cassaforte. Nulla viene sostituito e ciò che c\'è già viene saltato. + Note con checklist: %1$d + Tag: %1$d + Note che vanno nel cestino: %1$d + Già in questa cassaforte, saltate: %1$d + Ripetute nella tua selezione, saltate: %1$d + Destinazione: questa cassaforte + Allegati lasciati indietro: %1$d + Note fissate che non resteranno fissate: %1$d + Note archiviate che arrivano come note normali: %1$d + Colori delle note non mantenuti: %1$d + Tag in più scartati (una nota ha un tag): %1$d + Note attività importate come note normali: %1$d + Elementi cifrati o non supportati saltati: %1$d + File non utilizzabili: %1$d + Archivi dentro archivi, non aperti: %1$d + Importazione… + Importato + %1$d aggiunte, %2$d saltate perché duplicate, %3$d non importate. + I tuoi file originali non sono stati modificati. Eliminali tu quando hai controllato le note qui. + Di quale app sono? + I file sembrano provenire da più di un\'app. Scegli quella da importare; il resto viene saltato. + Non è stato importato nulla + Questo backup di Standard Notes è cifrato. Scarica un backup decifrato in Standard Notes e scegli quello. + Questo è un backup cifrato di Notesnook. In Notesnook esporta invece le note come Markdown e scegli il .zip. + Questi file non sono un\'esportazione supportata. Guarda le app supportate o scegli file Markdown o di testo. + Questa selezione è più grande di quanto Encly possa importare in una volta (64 MB di note, 2 GB in totale o 20.000 note). Importala a parti. + Nessuna nota trovata in questa selezione. + Non è stato possibile leggere i file scelti. + Questo telefono non ha un\'app di selezione file che Encly possa usare. + La cassaforte si è chiusa durante l\'importazione. Sblocca Encly e riprova. + Qualcosa è andato storto e non è stato importato nulla. La tua cassaforte è invariata. + %1$d di %2$d selezionate + Seleziona tutto + Deseleziona tutto + Tutte + Solo nuove + Nel cestino + Senza titolo + Checklist + Nel cestino + Già nella cassaforte + Importa %1$d + Scegli di nuovo + diff --git a/app/src/main/res/values-it/strings_security.xml b/app/src/main/res/values-it/strings_security.xml index 22e2043a..ba9a4ec1 100644 --- a/app/src/main/res/values-it/strings_security.xml +++ b/app/src/main/res/values-it/strings_security.xml @@ -64,6 +64,9 @@ Dopo 30 secondi Dopo 1 minuto Dopo 2 minuti + Dopo 15 minuti + Dopo 30 minuti + Un intervallo più lungo lascia aperto il vault in background. Chiunque abbia accesso al telefono sbloccato può vedere le tue note. Come Encly protegge le tue note Una chiave casuale a 256 bit L’intero database è cifrato con una chiave creata su questo telefono. @@ -95,4 +98,30 @@ PIN di cancellazione impostato PIN di cancellazione disattivato La chiave del tuo PIN è stata reimpostata, quindi l\'eventuale PIN di cancellazione è stato disattivato. Impostalo di nuovo se ti serve. + Questo PIN è già in uso. Scegline un altro. + PIN di coercizione + Un secondo PIN che apre una cassaforte separata con note proprie + Disattivato quando la chiave del tuo PIN è stata reimpostata. Impostalo di nuovo se ne vuoi uno. + Se qualcuno ti costringe ad aprire Encly, digita il PIN di coercizione invece del tuo PIN. Encly apre una cassaforte separata e vuota che puoi usare normalmente: le note che aggiungi lì restano al suo interno. + La tua vera cassaforte resta bloccata e intatta. A differenza del PIN di cancellazione, il PIN di coercizione non cancella nulla. La tua impronta e la frase di recupero aprono sempre la cassaforte vera. + Ti protegge quando qualcuno ti dice di aprire l\'app e mostrargliela, non da un esame forense del telefono. Durante un blocco dopo tentativi falliti anche il PIN di coercizione non funziona. + Un PIN di coercizione è impostato. + Nessun PIN di coercizione è impostato. + Imposta PIN di coercizione + Cambia PIN di coercizione + Disattiva PIN di coercizione + Conferma il tuo PIN per impostare il PIN di coercizione + Crea un PIN di coercizione + 6 cifre, diverse dal tuo PIN + Conferma il PIN di coercizione + Digita di nuovo il PIN di coercizione + Il PIN di coercizione deve essere diverso dal tuo PIN + Impossibile salvare il PIN di coercizione. Riprova. + PIN di coercizione impostato. + PIN di coercizione cambiato. La cassaforte civetta conserva le sue note. + PIN di coercizione disattivato + La chiave del tuo PIN è stata reimpostata, quindi il PIN di coercizione ha smesso di funzionare. Impostalo di nuovo: la cassaforte civetta conserva le sue note. + Disattivare il PIN di coercizione? + La cassaforte civetta e le note che contiene vengono eliminate. La tua vera cassaforte non è toccata. + Disattiva diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index 55fc7e36..cb7b9a04 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -148,6 +148,10 @@ Encly meldt dat de kluis beschadigd is Tekstgrootte + Regelafstand + Standaard + Compact + Ruim Redactioneel Modern Technisch @@ -552,6 +556,7 @@ Toetsenbord tonen Toetsenbord verbergen Alle gegevens wissen? + De gegevens konden niet worden gewist. Er is niets gewijzigd. Wissen Subtaken Subtaak @@ -570,4 +575,30 @@ %1$d van %2$d subtaak voltooid %1$d van %2$d subtaken voltooid + Blok invoegen + Zoeken in notitie + Zoeken in deze notitie + %1$d van %2$d + Geen resultaten + Volgend resultaat + Vorig resultaat + Zoeken sluiten + Blok dupliceren + Beginnen met een sjabloon + Leeg + Vergadering + Dagboek + Projectnotities + Deelnemers + Agenda + Notities + Actiepunten + Vandaag + Dankbaarheid + Morgen + Doel + Taken + Beslissingen + Open vragen + Te doen diff --git a/app/src/main/res/values-nl/strings_import.xml b/app/src/main/res/values-nl/strings_import.xml new file mode 100644 index 00000000..32d0ece5 --- /dev/null +++ b/app/src/main/res/values-nl/strings_import.xml @@ -0,0 +1,80 @@ + + + + Importeren uit andere apps + Notities importeren + Uit Google Keep, Standard Notes, Joplin, Notesnook of Markdown-bestanden + Haal notities uit een andere app. Kies de export en Encly herkent het formaat zelf. Alles blijft op deze telefoon; er wordt niets geüpload. + Bestanden kiezen + Map kiezen + Ondersteunde apps + Zo exporteer je + Niet geïmporteerd + Google Keep + Google Takeout-.zip (kies hem zoals hij is, niet uitpakken) of losse .json-notities + Selecteer in Google Takeout alleen Keep, maak de export en download de .zip. Kies die .zip dan hier. + Vastgezet- en archiefstatus, kleuren en bijlagen. Alleen het eerste label wordt de tag. + Standard Notes + Ontsleutelde back-up (.txt of .json) of de .zip van een ontsleutelde download + Download in Standard Notes een ontsleutelde back-up, geen versleutelde. Kies dan het bestand of de .zip hier. + Versleutelde back-ups worden geweigerd. Vastgezet- en archiefstatus, bestanden en andere itemtypes. Alleen de eerste tag blijft. + Joplin + JEX-archief (.jex), RAW-map of Markdown-export + Kies in Joplin Bestand → Alles exporteren en dan JEX of RAW. Kies dan het bestand of de map hier. + Bijlagen en de taakstatus (taken komen aan als gewone notities). Een notitie krijgt zijn eerste tag, anders de naam van zijn notitieboek. + Notesnook + Markdown-, Markdown-met-front-matter- of TXT-export (.zip) + Exporteer je notities in Notesnook als Markdown met front matter om data en tags te behouden. Kies dan de .zip hier. Versleutelde back-ups zijn niet te importeren. + Bijlagen, vastgezet- en archiefstatus, kleuren. De eerste tag, anders het notitieboek, wordt de tag. + Markdown en tekst + .md- en .txt-bestanden of een hele map, bijvoorbeeld uit Obsidian of Markor + Kies de bestanden, of de map waarin ze staan. De bestandsnaam wordt de titel; eenvoudige front matter (titel, tags, data) wordt gelezen. + Afbeeldingen, bijlagen, plug-ins en opmaak buiten koppen, lijsten, checklists en citaten. De eerste map wordt de tag. + Je bestanden worden gelezen en herkend… + Klaar om te importeren + Herkend: %1$s · %2$s + De aangevinkte notities worden aan deze kluis toegevoegd. Er wordt niets vervangen en wat er al is, wordt overgeslagen. + Notities met checklists: %1$d + Tags: %1$d + Notities die naar de prullenbak gaan: %1$d + Al in deze kluis, overgeslagen: %1$d + Herhaald in je selectie, overgeslagen: %1$d + Bestemming: deze kluis + Achterblijvende bijlagen: %1$d + Vastgezette notities die niet vastgezet blijven: %1$d + Gearchiveerde notities die als gewone notities aankomen: %1$d + Notitiekleuren die niet blijven: %1$d + Extra tags weggelaten (een notitie heeft één tag): %1$d + Takennotities geïmporteerd als gewone notities: %1$d + Versleutelde of niet-ondersteunde items overgeslagen: %1$d + Bestanden die niet bruikbaar waren: %1$d + Archieven in archieven, niet geopend: %1$d + Importeren… + Geïmporteerd + %1$d toegevoegd, %2$d overgeslagen als dubbel, %3$d niet geïmporteerd. + Je originele bestanden zijn niet gewijzigd. Verwijder ze zelf zodra je de notities hier hebt gecontroleerd. + Uit welke app komen deze? + De bestanden lijken uit meer dan één app te komen. Kies de app om te importeren; de rest wordt overgeslagen. + Er is niets geïmporteerd + Deze Standard Notes-back-up is versleuteld. Download in Standard Notes een ontsleutelde back-up en kies die. + Dit is een versleutelde Notesnook-back-up. Exporteer je notities in Notesnook in plaats daarvan als Markdown en kies de .zip. + Deze bestanden zijn geen ondersteunde export. Bekijk de ondersteunde apps of kies Markdown- of tekstbestanden. + Deze selectie is groter dan Encly in één keer kan importeren (64 MB aan notities, 2 GB in totaal of 20.000 notities). Importeer hem in delen. + Er zijn geen notities gevonden in deze selectie. + De gekozen bestanden konden niet worden gelezen. + Deze telefoon heeft geen bestandskiezer die Encly kan gebruiken. + De kluis is tijdens het importeren gesloten. Ontgrendel Encly en probeer het opnieuw. + Er ging iets mis en er is niets geïmporteerd. Je kluis is ongewijzigd. + %1$d van %2$d geselecteerd + Alles selecteren + Niets selecteren + Alle + Alleen nieuwe + In prullenbak + Zonder titel + Checklist + In prullenbak + Al in de kluis + %1$d importeren + Opnieuw kiezen + diff --git a/app/src/main/res/values-nl/strings_security.xml b/app/src/main/res/values-nl/strings_security.xml index 845d461b..2af80eb7 100644 --- a/app/src/main/res/values-nl/strings_security.xml +++ b/app/src/main/res/values-nl/strings_security.xml @@ -59,6 +59,9 @@ Na 30 seconden Na 1 minuut Na 2 minuten + Na 15 minuten + Na 30 minuten + Bij een langere tijd blijft de kluis op de achtergrond open. Wie toegang heeft tot je ontgrendelde telefoon, kan je notities zien. Zo beschermt Encly je notities Een willekeurige 256-bits sleutel De hele database is versleuteld met een sleutel die op deze telefoon is gemaakt. @@ -90,4 +93,30 @@ Wis-pincode ingesteld Wis-pincode uitgezet De sleutel van je pincode is gereset, dus een eventuele wis-pincode is uitgeschakeld. Stel hem opnieuw in als je er een wilt. + Deze pincode is al in gebruik. Kies een andere. + Dwangpincode + Een tweede pincode die een aparte kluis met eigen notities opent + Uitgeschakeld toen je pinsleutel werd gereset. Stel hem opnieuw in als je er een wilt. + Als iemand je dwingt Encly te openen, voer dan de dwangpincode in plaats van je pincode in. Encly opent een aparte, lege kluis die je gewoon kunt gebruiken: notities die je daar toevoegt, blijven daarin bewaard. + Je echte kluis blijft vergrendeld en onaangeroerd. Anders dan de wis-pincode wist de dwangpincode niets. Je vingerafdruk en je herstelzin openen altijd de echte kluis. + Hij beschermt je als iemand zegt dat je de app moet openen en alles moet laten zien, niet tegen een forensisch onderzoek van de telefoon. Tijdens een vergrendeling na foute pogingen werkt de dwangpincode ook niet. + Er is een dwangpincode ingesteld. + Er is geen dwangpincode ingesteld. + Dwangpincode instellen + Dwangpincode wijzigen + Dwangpincode uitschakelen + Bevestig je pincode om de dwangpincode in te stellen + Een dwangpincode maken + 6 cijfers, anders dan je pincode + Bevestig de dwangpincode + Voer de dwangpincode nogmaals in + De dwangpincode moet anders zijn dan je pincode + De dwangpincode kon niet worden opgeslagen. Probeer het opnieuw. + Dwangpincode ingesteld. + Dwangpincode gewijzigd. De schijnkluis behoudt zijn notities. + Dwangpincode uitgeschakeld + Je pinsleutel is gereset, dus de dwangpincode werkt niet meer. Stel hem opnieuw in; de schijnkluis behoudt zijn notities. + De dwangpincode uitschakelen? + De schijnkluis en de notities daarin worden verwijderd. Je echte kluis blijft ongemoeid. + Uitschakelen diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 329ea29f..4a81a22a 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -148,6 +148,10 @@ Encly zgłasza uszkodzenie sejfu Rozmiar tekstu + Odstępy między wierszami + Domyślne + Kompaktowe + Przestronne Redakcyjny Nowoczesny Techniczny @@ -562,6 +566,7 @@ Pokaż klawiaturę Ukryj klawiaturę Wymazać wszystkie dane? + Nie udało się usunąć danych. Nic nie zostało zmienione. Wymaż Podzadania Podzadanie @@ -582,4 +587,30 @@ Wykonano %1$d z %2$d podzadań Wykonano %1$d z %2$d podzadania + Wstaw blok + Znajdź w notatce + Znajdź w tej notatce + %1$d z %2$d + Brak wyników + Następny wynik + Poprzedni wynik + Zamknij wyszukiwanie + Duplikuj blok + Zacznij od szablonu + Pusta + Spotkanie + Dziennik + Notatki projektowe + Uczestnicy + Program + Notatki + Zadania do wykonania + Dzisiaj + Wdzięczność + Jutro + Cel + Zadania + Decyzje + Otwarte pytania + Do zrobienia diff --git a/app/src/main/res/values-pl/strings_import.xml b/app/src/main/res/values-pl/strings_import.xml new file mode 100644 index 00000000..df2a0c2c --- /dev/null +++ b/app/src/main/res/values-pl/strings_import.xml @@ -0,0 +1,80 @@ + + + + Import z innych aplikacji + Importuj notatki + Z Google Keep, Standard Notes, Joplin, Notesnook lub plików Markdown + Przenieś notatki z innej aplikacji. Wybierz jej eksport, a Encly sam rozpozna format. Wszystko zostaje na tym telefonie, nic nie jest wysyłane. + Wybierz pliki + Wybierz folder + Obsługiwane aplikacje + Jak wyeksportować + Nie zostanie zaimportowane + Google Keep + Plik .zip z Google Takeout (wybierz go bez rozpakowywania) lub pojedyncze notatki .json + W Google Takeout zaznacz tylko Keep, utwórz eksport i pobierz plik .zip. Potem wybierz ten .zip tutaj. + Stan przypięcia i archiwizacji, kolory i załączniki. Tagiem zostaje tylko pierwsza etykieta. + Standard Notes + Odszyfrowana kopia (.txt lub .json) albo .zip odszyfrowanego pobrania + W Standard Notes pobierz odszyfrowaną kopię, a nie zaszyfrowaną. Potem wybierz plik lub .zip tutaj. + Zaszyfrowane kopie są odrzucane. Stan przypięcia i archiwizacji, pliki i inne typy elementów. Zachowany zostaje tylko pierwszy tag. + Joplin + Archiwum JEX (.jex), folder RAW lub eksport Markdown + W Joplin wybierz File → Export all, a potem JEX lub RAW. Następnie wybierz plik lub folder tutaj. + Załączniki i status zadań (zadania trafiają jako zwykłe notatki). Notatka dostaje swój pierwszy tag, a gdy go nie ma, nazwę notatnika. + Notesnook + Eksport Markdown, Markdown z front matter lub TXT (.zip) + W Notesnook wyeksportuj notatki jako Markdown z front matter, aby zachować daty i tagi. Potem wybierz .zip tutaj. Zaszyfrowanych kopii nie można importować. + Załączniki, stan przypięcia i archiwizacji, kolory. Tagiem zostaje pierwszy tag, a gdy go nie ma, notatnik. + Markdown i tekst + Pliki .md i .txt albo cały folder, np. z Obsidiana lub Markora + Wybierz pliki albo folder, w którym są. Nazwa pliku staje się tytułem; prosty front matter (tytuł, tagi, daty) jest odczytywany. + Obrazy, załączniki, wtyczki i formatowanie poza nagłówkami, listami, listami kontrolnymi i cytatami. Tagiem zostaje pierwszy folder. + Czytam i rozpoznaję Twoje pliki… + Gotowe do importu + Rozpoznano: %1$s · %2$s + Zaznaczone notatki zostaną dodane do tego sejfu. Nic nie jest zastępowane, a to, co już tu jest, zostaje pominięte. + Notatki z listami kontrolnymi: %1$d + Tagi: %1$d + Notatki, które trafią do kosza: %1$d + Już w tym sejfie, pominięto: %1$d + Powtórzone w Twoim wyborze, pominięto: %1$d + Miejsce docelowe: ten sejf + Załączniki, które zostaną: %1$d + Przypięte notatki, które nie pozostaną przypięte: %1$d + Zarchiwizowane notatki, które trafią jako zwykłe: %1$d + Kolory notatek, które nie zostaną zachowane: %1$d + Pominięte dodatkowe tagi (notatka ma jeden tag): %1$d + Notatki-zadania zaimportowane jako zwykłe notatki: %1$d + Pominięte elementy zaszyfrowane lub nieobsługiwane: %1$d + Pliki, których nie dało się użyć: %1$d + Archiwa w archiwach, nieotwarte: %1$d + Importuję… + Zaimportowano + Dodano: %1$d, pominięto jako duplikaty: %2$d, nie zaimportowano: %3$d. + Twoje oryginalne pliki nie zostały zmienione. Usuń je sam po sprawdzeniu notatek tutaj. + Z jakiej aplikacji są te pliki? + Pliki wyglądają na pochodzące z więcej niż jednej aplikacji. Wybierz tę do zaimportowania; reszta zostanie pominięta. + Nic nie zaimportowano + Ta kopia Standard Notes jest zaszyfrowana. Pobierz w Standard Notes odszyfrowaną kopię i wybierz ją. + To zaszyfrowana kopia Notesnook. W Notesnook wyeksportuj notatki jako Markdown i wybierz plik .zip. + Te pliki nie są obsługiwanym eksportem. Sprawdź obsługiwane aplikacje albo wybierz pliki Markdown lub tekstowe. + Ten wybór jest większy, niż Encly może zaimportować naraz (64 MB notatek, 2 GB łącznie lub 20 000 notatek). Zaimportuj go w częściach. + W tym wyborze nie znaleziono notatek. + Nie udało się odczytać wybranych plików. + Na tym telefonie nie ma aplikacji do wybierania plików, której Encly mógłby użyć. + Sejf zamknął się podczas importu. Odblokuj Encly i spróbuj ponownie. + Coś poszło nie tak i nic nie zaimportowano. Twój sejf pozostał bez zmian. + Zaznaczono %1$d z %2$d + Zaznacz wszystkie + Odznacz wszystkie + Wszystkie + Tylko nowe + W koszu + Bez tytułu + Lista kontrolna + W koszu + Już w sejfie + Importuj: %1$d + Wybierz ponownie + diff --git a/app/src/main/res/values-pl/strings_security.xml b/app/src/main/res/values-pl/strings_security.xml index c676477b..0b2711ba 100644 --- a/app/src/main/res/values-pl/strings_security.xml +++ b/app/src/main/res/values-pl/strings_security.xml @@ -69,6 +69,9 @@ Po 30 sekundach Po 1 minucie Po 2 minutach + Po 15 minutach + Po 30 minutach + Dłuższy czas pozostawia sejf otwarty w tle. Osoba z dostępem do odblokowanego telefonu może zobaczyć Twoje notatki. Jak Encly chroni twoje notatki Losowy klucz 256-bitowy Cała baza jest zaszyfrowana kluczem utworzonym na tym telefonie. @@ -100,4 +103,30 @@ PIN kasujący ustawiony PIN kasujący wyłączony Klucz twojego PIN-u został zresetowany, więc ewentualny PIN kasujący został wyłączony. Ustaw go ponownie, jeśli chcesz. + Ten PIN jest już używany. Wybierz inny. + PIN przymusowy + Drugi PIN, który otwiera osobny sejf z własnymi notatkami + Wyłączony, gdy klucz twojego PIN-u został zresetowany. Ustaw go ponownie, jeśli chcesz go mieć. + Jeśli ktoś zmusza cię do otwarcia Encly, wpisz PIN przymusowy zamiast swojego PIN-u. Encly otworzy osobny, pusty sejf, z którego możesz korzystać jak zwykle: dodane tam notatki w nim zostają. + Twój prawdziwy sejf pozostaje zablokowany i nietknięty. W przeciwieństwie do PIN-u kasującego PIN przymusowy niczego nie usuwa. Odcisk palca i fraza odzyskiwania zawsze otwierają prawdziwy sejf. + Chroni cię, gdy ktoś każe ci otworzyć aplikację i wszystko pokazać, ale nie przed kryminalistycznym badaniem telefonu. W czasie blokady po błędnych próbach PIN przymusowy też nie działa. + PIN przymusowy jest ustawiony. + PIN przymusowy nie jest ustawiony. + Ustaw PIN przymusowy + Zmień PIN przymusowy + Wyłącz PIN przymusowy + Potwierdź swój PIN, aby ustawić PIN przymusowy + Utwórz PIN przymusowy + 6 cyfr, inny niż twój PIN + Potwierdź PIN przymusowy + Wpisz PIN przymusowy ponownie + PIN przymusowy musi być inny niż twój PIN + Nie udało się zapisać PIN-u przymusowego. Spróbuj ponownie. + PIN przymusowy ustawiony. + PIN przymusowy zmieniony. Sejf-przynęta zachowuje swoje notatki. + PIN przymusowy wyłączony + Klucz twojego PIN-u został zresetowany, więc PIN przymusowy przestał działać. Ustaw go ponownie; sejf-przynęta zachowuje swoje notatki. + Wyłączyć PIN przymusowy? + Sejf-przynęta i notatki w nim zostaną usunięte. Twój prawdziwy sejf pozostanie bez zmian. + Wyłącz diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index 44453b8f..149a2e75 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -148,6 +148,10 @@ O Encly informa que o cofre está danificado Tamanho do texto + Espaçamento entre linhas + Padrão + Compacto + Espaçoso Editorial Moderno Técnico @@ -557,6 +561,7 @@ Mostrar teclado Ocultar teclado Apagar todos os dados? + Não foi possível apagar os dados. Nada foi alterado. Apagar Subtarefas Subtarefa @@ -576,4 +581,30 @@ %1$d de %2$d subtarefas concluídas %1$d de %2$d subtarefas concluídas + Inserir bloco + Localizar na nota + Localizar nesta nota + %1$d de %2$d + Sem resultados + Próximo resultado + Resultado anterior + Fechar busca + Duplicar bloco + Começar com um modelo + Em branco + Reunião + Diário + Notas de projeto + Participantes + Pauta + Anotações + Ações a realizar + Hoje + Gratidão + Amanhã + Objetivo + Tarefas + Decisões + Perguntas em aberto + A fazer diff --git a/app/src/main/res/values-pt/strings_import.xml b/app/src/main/res/values-pt/strings_import.xml new file mode 100644 index 00000000..aa78f301 --- /dev/null +++ b/app/src/main/res/values-pt/strings_import.xml @@ -0,0 +1,80 @@ + + + + Importar de outros apps + Importar notas + Do Google Keep, Standard Notes, Joplin, Notesnook ou arquivos Markdown + Traga notas de outro app. Escolha a exportação dele e o Encly reconhece o formato sozinho. Tudo fica neste telefone; nada é enviado. + Escolher arquivos + Escolher pasta + Apps compatíveis + Como exportar + Não importado + Google Keep + .zip do Google Takeout (escolha como está, sem descompactar) ou notas .json avulsas + No Google Takeout selecione só o Keep, crie a exportação e baixe o .zip. Depois escolha esse .zip aqui. + Estado de fixada e arquivada, cores e anexos. Só o primeiro marcador vira a tag. + Standard Notes + Backup descriptografado (.txt ou .json) ou o .zip de um download descriptografado + No Standard Notes baixe um backup descriptografado, não um criptografado. Depois escolha o arquivo ou o .zip aqui. + Backups criptografados são recusados. Estado de fixada e arquivada, arquivos e outros tipos de item. Só a primeira tag é mantida. + Joplin + Arquivo JEX (.jex), pasta RAW ou exportação Markdown + No Joplin escolha File → Export all e depois JEX ou RAW. Depois escolha o arquivo ou a pasta aqui. + Anexos e o status das tarefas (as tarefas chegam como notas comuns). Uma nota recebe a primeira tag; sem tag, o nome do caderno. + Notesnook + Exportação Markdown, Markdown com front matter ou TXT (.zip) + No Notesnook exporte as notas como Markdown com front matter para manter datas e tags. Depois escolha o .zip aqui. Backups criptografados não podem ser importados. + Anexos, estado de fixada e arquivada, cores. A primeira tag, ou o caderno se não houver, vira a tag. + Markdown e texto + Arquivos .md e .txt ou uma pasta inteira, por exemplo do Obsidian ou do Markor + Escolha os arquivos ou a pasta que os contém. O nome do arquivo vira o título; o front matter simples (título, tags, datas) é lido. + Imagens, anexos, plugins e formatação além de títulos, listas, checklists e citações. A primeira pasta vira a tag. + Lendo e reconhecendo seus arquivos… + Pronto para importar + Detectado: %1$s · %2$s + As notas marcadas são adicionadas a este cofre. Nada é substituído e o que já está aqui é ignorado. + Notas com checklists: %1$d + Tags: %1$d + Notas que vão para a lixeira: %1$d + Já neste cofre, ignoradas: %1$d + Repetidas na sua seleção, ignoradas: %1$d + Destino: este cofre + Anexos que ficam para trás: %1$d + Notas fixadas que não continuarão fixadas: %1$d + Notas arquivadas que chegam como notas comuns: %1$d + Cores de nota não mantidas: %1$d + Tags extras descartadas (uma nota tem uma tag): %1$d + Notas de tarefa importadas como notas comuns: %1$d + Itens criptografados ou sem suporte ignorados: %1$d + Arquivos que não puderam ser usados: %1$d + Arquivos dentro de arquivos, não abertos: %1$d + Importando… + Importado + %1$d adicionadas, %2$d ignoradas como duplicadas, %3$d não importadas. + Seus arquivos originais não foram alterados. Apague-os você mesmo depois de conferir as notas aqui. + De qual app são? + Os arquivos parecem vir de mais de um app. Escolha o que importar; o resto é ignorado. + Nada foi importado + Este backup do Standard Notes está criptografado. Baixe um backup descriptografado no Standard Notes e escolha-o. + Este é um backup criptografado do Notesnook. No Notesnook exporte as notas como Markdown e escolha o .zip. + Estes arquivos não são uma exportação compatível. Veja os apps compatíveis ou escolha arquivos Markdown ou de texto. + Esta seleção é maior do que o Encly consegue importar de uma vez (64 MB de notas, 2 GB no total ou 20.000 notas). Importe em partes. + Nenhuma nota foi encontrada nesta seleção. + Não foi possível ler os arquivos escolhidos. + Este telefone não tem um app seletor de arquivos que o Encly possa usar. + O cofre foi fechado durante a importação. Desbloqueie o Encly e tente de novo. + Algo deu errado e nada foi importado. Seu cofre não mudou. + %1$d de %2$d selecionadas + Selecionar tudo + Limpar seleção + Todas + Só novas + Na lixeira + Sem título + Checklist + Na lixeira + Já no cofre + Importar %1$d + Escolher de novo + diff --git a/app/src/main/res/values-pt/strings_security.xml b/app/src/main/res/values-pt/strings_security.xml index 1b9da146..e97abf45 100644 --- a/app/src/main/res/values-pt/strings_security.xml +++ b/app/src/main/res/values-pt/strings_security.xml @@ -64,6 +64,9 @@ Após 30 segundos Após 1 minuto Após 2 minutos + Após 15 minutos + Após 30 minutos + Um prazo maior deixa o cofre aberto em segundo plano. Quem tiver acesso ao telefone desbloqueado poderá ver suas notas. Como o Encly protege as suas notas Uma chave aleatória de 256 bits Todo o banco de dados é criptografado com uma chave criada neste telefone. @@ -95,4 +98,30 @@ PIN de apagamento definido PIN de apagamento desativado A chave do seu PIN foi redefinida, então o PIN de apagamento, se houver, foi desativado. Defina-o de novo se quiser um. + Este PIN já está em uso. Escolha outro diferente. + PIN de coação + Um segundo PIN que abre um cofre separado com notas próprias + Desativado quando a chave do seu PIN foi redefinida. Defina-o de novo se quiser um. + Se alguém obrigar você a abrir o Encly, digite o PIN de coação em vez do seu PIN. O Encly abre um cofre separado e vazio que você pode usar normalmente: as notas que você adicionar ali ficam guardadas nele. + Seu cofre real continua bloqueado e intacto. Diferente do PIN de apagamento, o PIN de coação não apaga nada. Sua digital e sua frase de recuperação sempre abrem o cofre real. + Ele protege quando alguém manda você abrir o app e mostrar tudo, não contra um exame forense do telefone. Durante um bloqueio após tentativas erradas, o PIN de coação também não funciona. + Há um PIN de coação definido. + Nenhum PIN de coação definido. + Definir PIN de coação + Alterar PIN de coação + Desativar PIN de coação + Confirme seu PIN para configurar o PIN de coação + Criar um PIN de coação + 6 dígitos, diferentes do seu PIN + Confirmar o PIN de coação + Digite o PIN de coação novamente + O PIN de coação deve ser diferente do seu PIN + Não foi possível salvar o PIN de coação. Tente novamente. + PIN de coação definido. + PIN de coação alterado. O cofre isca mantém as suas notas. + PIN de coação desativado + A chave do seu PIN foi redefinida, então o PIN de coação parou de funcionar. Defina-o de novo; o cofre isca mantém as suas notas. + Desativar o PIN de coação? + O cofre isca e as notas dentro dele são excluídos. Seu cofre real não é afetado. + Desativar diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index 700c428e..cdbfe3ac 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -148,6 +148,10 @@ Encly повідомляє про пошкодження сховища Розмір тексту + Міжряддя + Звичайне + Компактне + Просторе Редакційний Сучасний Технічний @@ -562,6 +566,7 @@ Показати клавіатуру Сховати клавіатуру Стерти всі дані? + Не вдалося стерти дані. Нічого не змінено. Стерти Підзавдання Підзавдання @@ -582,4 +587,30 @@ %1$d з %2$d підзавдань виконано %1$d з %2$d підзавдання виконано + Вставити блок + Знайти в нотатці + Знайти в цій нотатці + %1$d з %2$d + Збігів немає + Наступний збіг + Попередній збіг + Закрити пошук + Дублювати блок + Почати із шаблону + Порожня + Зустріч + Щоденник + Нотатки проєкту + Учасники + Порядок денний + Нотатки + Дії до виконання + Сьогодні + Вдячність + Завтра + Мета + Завдання + Рішення + Відкриті питання + Зробити diff --git a/app/src/main/res/values-uk/strings_import.xml b/app/src/main/res/values-uk/strings_import.xml new file mode 100644 index 00000000..dd0928bd --- /dev/null +++ b/app/src/main/res/values-uk/strings_import.xml @@ -0,0 +1,80 @@ + + + + Імпорт з інших застосунків + Імпорт нотаток + З Google Keep, Standard Notes, Joplin, Notesnook або файлів Markdown + Перенесіть нотатки з іншого застосунку. Оберіть його експорт, а Encly сам розпізнає формат. Усе залишається на цьому телефоні, нічого не завантажується. + Обрати файли + Обрати папку + Підтримувані застосунки + Як експортувати + Не імпортується + Google Keep + Архів Google Takeout (.zip) як є, без розпакування, або окремі нотатки .json + У Google Takeout оберіть лише Keep, створіть експорт і завантажте .zip. Потім оберіть цей .zip тут. + Закріплення й архівація, кольори та вкладення. Тегом стає лише перша мітка. + Standard Notes + Розшифрована резервна копія (.txt або .json) або .zip розшифрованого завантаження + У Standard Notes завантажте розшифровану резервну копію, а не зашифровану. Потім оберіть файл або .zip тут. + Зашифровані копії відхиляються. Закріплення й архівація, файли та інші типи елементів. Зберігається лише перший тег. + Joplin + Архів JEX (.jex), папка RAW або експорт Markdown + У Joplin оберіть File → Export all і формат JEX або RAW. Потім оберіть файл або папку тут. + Вкладення та статус завдання (завдання стають звичайними нотатками). Нотатка отримує свій перший тег, а якщо його немає, то назву блокнота. + Notesnook + Експорт Markdown, Markdown з front matter або TXT (.zip) + У Notesnook експортуйте нотатки як Markdown з front matter, щоб зберегти дати й теги. Потім оберіть .zip тут. Зашифровані резервні копії імпортувати не можна. + Вкладення, закріплення й архівація, кольори. Тегом стає перший тег, а якщо його немає, то блокнот. + Markdown і текст + Файли .md і .txt або ціла папка, наприклад з Obsidian чи Markor + Оберіть файли або папку з ними. Назва файлу стає заголовком; простий front matter (заголовок, теги, дати) читається. + Зображення, вкладення, плагіни та форматування, крім заголовків, списків, чеклістів і цитат. Тегом стає перша папка. + Читаємо й розпізнаємо ваші файли… + Готово до імпорту + Розпізнано: %1$s · %2$s + Позначені нотатки буде додано до цього сховища. Нічого не замінюється, а те, що вже є, пропускається. + Нотаток зі списками завдань: %1$d + Тегів: %1$d + Нотаток, що підуть у кошик: %1$d + Уже є в цьому сховищі, пропущено: %1$d + Повторюється у вашому виборі, пропущено: %1$d + Куди: це сховище + Вкладень, що залишаться: %1$d + Закріплених нотаток, які більше не будуть закріплені: %1$d + Архівних нотаток, що стануть звичайними: %1$d + Кольорів нотаток не збережено: %1$d + Зайвих тегів відкинуто (нотатка має лише один тег): %1$d + Нотаток-завдань, імпортованих як звичайні нотатки: %1$d + Зашифрованих або непідтримуваних елементів пропущено: %1$d + Файлів, які не вдалося використати: %1$d + Архівів в архівах, не відкрито: %1$d + Імпортуємо… + Імпортовано + Додано: %1$d, пропущено як дублікати: %2$d, не імпортовано: %3$d. + Ваші початкові файли не змінено. Видаліть їх самі, коли перевірите нотатки тут. + З якого це застосунку? + Файли схожі на експорти з кількох застосунків. Оберіть той, який імпортувати; решту буде пропущено. + Нічого не імпортовано + Ця резервна копія Standard Notes зашифрована. Завантажте в Standard Notes розшифровану копію й оберіть її. + Це зашифрована резервна копія Notesnook. Експортуйте нотатки в Notesnook як Markdown і оберіть .zip. + Ці файли не є підтримуваним експортом. Перегляньте підтримувані застосунки або оберіть файли Markdown чи текстові. + Цей вибір більший, ніж Encly може імпортувати за раз (64 МБ нотаток, 2 ГБ загалом або 20 000 нотаток). Імпортуйте його частинами. + У цьому виборі не знайдено нотаток. + Не вдалося прочитати вибрані файли. + На цьому телефоні немає застосунку для вибору файлів, який може використати Encly. + Сховище закрилося під час імпорту. Розблокуйте Encly і спробуйте ще раз. + Щось пішло не так, нічого не імпортовано. Ваше сховище не змінилося. + Вибрано %1$d з %2$d + Вибрати всі + Зняти вибір + Усі + Лише нові + У кошику + Без назви + Чекліст + У кошику + Уже в сховищі + Імпортувати: %1$d + Обрати ще раз + diff --git a/app/src/main/res/values-uk/strings_security.xml b/app/src/main/res/values-uk/strings_security.xml index 9b1e2420..01f893d4 100644 --- a/app/src/main/res/values-uk/strings_security.xml +++ b/app/src/main/res/values-uk/strings_security.xml @@ -69,6 +69,9 @@ Через 30 секунд Через 1 хвилину Через 2 хвилини + Через 15 хвилин + Через 30 хвилин + Довший час залишає сховище відкритим у фоні. Людина з доступом до розблокованого телефона може побачити ваші нотатки. Як Encly захищає нотатки Випадковий 256-бітний ключ Уся база зашифрована ключем, створеним на цьому телефоні. @@ -100,4 +103,30 @@ PIN для стирання задано PIN для стирання вимкнено Ключ вашого PIN було скинуто, тож PIN для стирання, якщо він був, вимкнено. Задайте його знову, якщо він вам потрібен. + Цей PIN уже використовується. Оберіть інший. + PIN під примусом + Другий PIN, який відкриває окреме сховище з власними нотатками + Вимкнено, бо ключ вашого PIN було скинуто. Задайте його знову, якщо він вам потрібен. + Якщо вас змушують відкрити Encly, введіть PIN під примусом замість свого PIN. Encly відкриє окреме порожнє сховище, яким можна користуватися як звичайно: нотатки, додані там, залишаються в ньому. + Ваше справжнє сховище лишається заблокованим і недоторканим. На відміну від PIN для стирання, PIN під примусом нічого не стирає. Відбиток пальця та фраза відновлення завжди відкривають справжнє сховище. + Він захищає, коли хтось вимагає відкрити застосунок і все показати, але не від криміналістичної експертизи телефона. Під час блокування після невдалих спроб PIN під примусом теж не діє. + PIN під примусом задано. + PIN під примусом не задано. + Задати PIN під примусом + Змінити PIN під примусом + Вимкнути PIN під примусом + Підтвердьте свій PIN, щоб налаштувати PIN під примусом + Створіть PIN під примусом + 6 цифр, відмінні від вашого PIN + Підтвердьте PIN під примусом + Введіть PIN під примусом ще раз + PIN під примусом має відрізнятися від вашого PIN + Не вдалося зберегти PIN під примусом. Спробуйте ще раз. + PIN під примусом задано. + PIN під примусом змінено. Підставне сховище зберігає свої нотатки. + PIN під примусом вимкнено + Ключ вашого PIN було скинуто, тож PIN під примусом перестав діяти. Задайте його знову; підставне сховище зберігає свої нотатки. + Вимкнути PIN під примусом? + Підставне сховище та нотатки в ньому буде видалено. Ваше справжнє сховище це не зачепить. + Вимкнути diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 09e3a3dc..e1b51e0e 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -164,6 +164,10 @@ Encly says the vault is damaged Text size + Line spacing + Default + Compact + Comfortable Editorial Modern Technical @@ -569,6 +573,7 @@ Show keyboard Hide keyboard Erase all data? + Couldn\'t erase the data. Nothing was changed. Erase Sub-tasks Sub-task @@ -587,4 +592,30 @@ %1$d of %2$d sub-task done %1$d of %2$d sub-tasks done + Insert block + Find in note + Find in this note + %1$d of %2$d + No matches + Next match + Previous match + Close find + Duplicate block + Start from template + Blank + Meeting + Journal + Project notes + Attendees + Agenda + Notes + Action items + Today + Gratitude + Tomorrow + Goal + Tasks + Decisions + Open questions + To do diff --git a/app/src/main/res/values/strings_import.xml b/app/src/main/res/values/strings_import.xml new file mode 100644 index 00000000..991b207b --- /dev/null +++ b/app/src/main/res/values/strings_import.xml @@ -0,0 +1,82 @@ + + + + Import from other apps + Import notes + From Google Keep, Standard Notes, Joplin, Notesnook or Markdown files + Bring in notes from another app. Choose its export and Encly recognises the format itself. Everything stays on this phone; nothing is uploaded. + Choose files + Choose folder + Supported apps + How to export + Not imported + + Google Keep + Google Takeout .zip (choose it as it is, no need to unzip) or loose .json notes + In Google Takeout select only Keep, create the export and download the .zip. Then choose that .zip here. + Pinned and archived state, colours and attachments. Only the first label becomes the tag. + Standard Notes + Decrypted backup (.txt or .json), or the .zip of a decrypted download + In Standard Notes download a decrypted backup, not an encrypted one. Then choose the file or the .zip here. + Encrypted backups are refused. Pinned and archived state, files and other item types. Only the first tag is kept. + Joplin + JEX archive (.jex), RAW folder or Markdown export + In Joplin choose File → Export all and pick JEX or RAW. Then choose the file or folder here. + Attachments, and to-do status (to-dos arrive as ordinary notes). A note gets its first tag, otherwise its notebook name. + Notesnook + Markdown, Markdown with front matter or TXT export (.zip) + In Notesnook export your notes as Markdown with front matter to keep dates and tags. Then choose the .zip here. Encrypted backups cannot be imported. + Attachments, pinned and archived state, colours. The first tag, otherwise the notebook, becomes the tag. + Markdown and text + .md and .txt files or a whole folder, for example from Obsidian or Markor + Choose the files, or the folder that holds them. The file name is the title; simple front matter (title, tags, dates) is read. + Images, attachments, plugins and formatting beyond headings, lists, checklists and quotes. The first folder becomes the tag. + + Reading and recognising your files… + Ready to import + Detected: %1$s · %2$s + The ticked notes are added to this vault. Nothing is replaced, and anything already here is skipped. + Notes with checklists: %1$d + Tags: %1$d + Notes that go to the trash: %1$d + Already in this vault, skipped: %1$d + Repeated in your selection, skipped: %1$d + Destination: this vault + Attachments left behind: %1$d + Pinned notes that will not stay pinned: %1$d + Archived notes that arrive as normal notes: %1$d + Note colours not kept: %1$d + Extra tags dropped (a note has one tag): %1$d + To-do notes imported as ordinary notes: %1$d + Encrypted or unsupported entries skipped: %1$d + Files that could not be used: %1$d + Archives inside archives, not opened: %1$d + Importing… + Imported + %1$d added, %2$d skipped as duplicates, %3$d not imported. + Your original files were not changed. Delete them yourself once you have checked your notes here. + Which app are these from? + The files look like they come from more than one app. Choose the one to import; the rest is skipped. + Nothing was imported + This Standard Notes backup is encrypted. Download a decrypted backup in Standard Notes and choose that instead. + This is an encrypted Notesnook backup. In Notesnook export your notes as Markdown instead and choose the .zip. + These files are not a supported export. Check the supported apps, or choose Markdown or text files. + This selection is larger than Encly can import at once (64 MB of notes, 2 GB in all, or 20,000 notes). Import it in smaller parts. + No notes were found in this selection. + The selected files could not be read. + This phone has no file picker app that Encly can use. + The vault closed during the import. Unlock Encly and try again. + Something went wrong and nothing was imported. Your vault is unchanged. + %1$d of %2$d selected + Select all + Select none + All + New only + In trash + Untitled + Checklist + In trash + Already in vault + Import %1$d + Choose again + diff --git a/app/src/main/res/values/strings_security.xml b/app/src/main/res/values/strings_security.xml index 8ab4c270..1da0ef2f 100644 --- a/app/src/main/res/values/strings_security.xml +++ b/app/src/main/res/values/strings_security.xml @@ -59,6 +59,9 @@ After 30 seconds After 1 minute After 2 minutes + After 15 minutes + After 30 minutes + Longer delays leave your vault open in the background. Anyone with access to your unlocked phone may see your notes. How Encly protects your notes A random 256-bit key The whole database is encrypted with a key made on this phone. @@ -90,4 +93,30 @@ Wipe PIN set Wipe PIN turned off Your PIN key was reset, so any wipe PIN you had was turned off. Set it again if you want one. + This PIN is already in use. Choose a different one. + Duress PIN + A second PIN that opens a separate vault with its own notes + Turned off when your PIN key was reset. Set it again if you want one. + If someone makes you open Encly, type the duress PIN instead of your PIN. Encly opens a separate, empty vault that you can use like normal: the notes you add there stay in it. + Your real vault stays locked and untouched. Unlike the wipe PIN, the duress PIN doesn\'t erase anything. Your fingerprint and your recovery phrase always open the real vault. + It protects you when someone tells you to open the app and show them, not against a forensic examination of the phone. During a lockout the duress PIN can\'t be used either. + A duress PIN is set. + No duress PIN is set. + Set duress PIN + Change duress PIN + Turn off duress PIN + Confirm your PIN to set up the duress PIN + Create a duress PIN + 6 digits, different from your PIN + Confirm the duress PIN + Enter the duress PIN again + The duress PIN must be different from your PIN + Couldn\'t save the duress PIN. Please try again. + Duress PIN set. + Duress PIN changed. The decoy vault keeps its notes. + Duress PIN turned off + Your PIN key was reset, so the duress PIN stopped working. Set it again; the decoy vault keeps its notes. + Turn off the duress PIN? + The decoy vault and the notes in it are deleted. Your real vault is not affected. + Turn off diff --git a/app/src/test/java/com/pasich/encly/core/common/UiTextAndStringsTest.kt b/app/src/test/java/com/pasich/encly/core/common/UiTextAndStringsTest.kt new file mode 100644 index 00000000..502d5774 --- /dev/null +++ b/app/src/test/java/com/pasich/encly/core/common/UiTextAndStringsTest.kt @@ -0,0 +1,48 @@ +package com.pasich.encly.core.common + +import android.app.Application +import androidx.appcompat.app.AppCompatDelegate +import androidx.core.os.LocaleListCompat +import androidx.test.core.app.ApplicationProvider +import com.pasich.encly.R +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** Text kept as a resource id, or resolved for storing, follows the language picked in the app. */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [32]) // Android 12 and lower: the platform alone ignores the in-app language +class UiTextAndStringsTest { + private val context: Application get() = ApplicationProvider.getApplicationContext() + + @After + fun resetLocales() { + AppCompatDelegate.setApplicationLocales(LocaleListCompat.getEmptyLocaleList()) + } + + @Test + fun aResourceTextResolvesToItsString() { + assertEquals("Cancel", UiText.of(R.string.cancel).asString(context)) + assertEquals(UiText.Resource(R.string.cancel), UiText.of(R.string.cancel)) + } + + @Test + fun withoutAnAppLanguageStoredTextUsesTheSystemOne() { + assertEquals("Cancel", AppStrings(context).get(R.string.cancel)) + } + + @Test + fun storedTextFollowsTheLanguagePickedInTheApp() { + AppCompatDelegate.setApplicationLocales(LocaleListCompat.forLanguageTags("uk")) + + assertEquals("Скасувати", AppStrings(context).get(R.string.cancel)) + } + + @Test + fun storedTextTakesFormatArguments() { + assertEquals("Today 10:15", AppStrings(context).get(R.string.date_today_at, "10:15")) + } +} diff --git a/app/src/test/java/com/pasich/encly/core/security/AuthenticationEdgeCasesTest.kt b/app/src/test/java/com/pasich/encly/core/security/AuthenticationEdgeCasesTest.kt new file mode 100644 index 00000000..1dd3a98c --- /dev/null +++ b/app/src/test/java/com/pasich/encly/core/security/AuthenticationEdgeCasesTest.kt @@ -0,0 +1,228 @@ +package com.pasich.encly.core.security + +import com.pasich.encly.testutil.FakeLockoutClock +import com.pasich.encly.testutil.FakePinFactor +import com.pasich.encly.testutil.tempVaultStore +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +/** + * The refusals and odd states around the PIN slots: PINs that may not share a slot, a + * Keystore that fails mid-change, and store entries an older or interrupted build left behind. + */ +class AuthenticationEdgeCasesTest { + private val store = tempVaultStore() + private val factor = FakePinFactor() + private val clock = FakeLockoutClock() + private val auth = AuthenticationManager(store, factor, clock) + private val dek = ByteArray(32) { 0x3C } + + @Before + fun setUp() { + assertTrue(auth.configurePin(PIN.toCharArray(), dek)) + } + + private fun duress(pin: String) = auth.configureDuressPin(pin.toCharArray(), dek) { true } + + private fun failures() = store.getInt("lockout.failures", 0) + + // --- one PIN, one slot -------------------------------------------------------------- + + @Test + fun theWipePinCanNotBeThePinAndTheTryCostsAMiss() { + assertEquals(WipePinChange.SAME_AS_PIN, auth.configureWipePin(PIN.toCharArray())) + + assertEquals(1, failures()) + } + + @Test + fun theWipePinCanNotBeTheDuressPin() { + assertEquals(DuressPinChange.SET_NEW_VAULT, duress(DURESS)) + + assertEquals(WipePinChange.IN_USE, auth.configureWipePin(DURESS.toCharArray())) + assertEquals(1, failures()) + } + + @Test + fun theDuressPinCanNotBeThePinAndTheTryCostsAMiss() { + assertEquals(DuressPinChange.SAME_AS_PIN, duress(PIN)) + + assertEquals(1, failures()) + } + + @Test + fun theDuressPinCanNotBeTheWipePin() { + assertEquals(WipePinChange.SET, auth.configureWipePin(WIPE.toCharArray())) + + assertEquals(DuressPinChange.IN_USE, duress(WIPE)) + assertEquals(1, failures()) + } + + @Test + fun theVaultPinCanNotBecomeTheDuressPin() { + assertEquals(DuressPinChange.SET_NEW_VAULT, duress(DURESS)) + + assertEquals(PinChange.IN_USE, auth.changePin(DURESS.toCharArray(), dek)) + assertEquals(1, failures()) + assertTrue("the old PIN still works", auth.verifyPinAuth(PIN.toCharArray())) + } + + @Test + fun changingThePinToTheWipePinTurnsTheWipePinOffInsteadOfKeepingTwoSlots() { + assertEquals(WipePinChange.SET, auth.configureWipePin(WIPE.toCharArray())) + + assertEquals(PinChange.SET, auth.changePin(WIPE.toCharArray(), dek)) + + val unlock = auth.unlockWithPin(WIPE.toCharArray()) + assertTrue("the PIN opens the vault rather than erasing it", unlock is PinUnlock.Success) + (unlock as PinUnlock.Success).dek.fill(0) + } + + // --- refusals before any key is touched ----------------------------------------------- + + @Test + fun malformedPinsAreRefusedEverywhere() { + listOf("12345", "1234567", "12345a", "").forEach { bad -> + assertEquals(PinChange.FAILED, auth.changePin(bad.toCharArray(), dek)) + assertEquals(WipePinChange.FAILED, auth.configureWipePin(bad.toCharArray())) + assertEquals(DuressPinChange.FAILED, duress(bad)) + } + assertEquals(0, failures()) + } + + @Test + fun aKeyOfTheWrongSizeIsRefused() { + assertEquals(PinChange.FAILED, auth.changePin(NEW_PIN.toCharArray(), ByteArray(31))) + assertEquals(DuressPinChange.FAILED, auth.configureDuressPin(DURESS.toCharArray(), ByteArray(16)) { true }) + } + + @Test + fun nothingIsConfiguredWithoutAPinSlot() { + val empty = AuthenticationManager(tempVaultStore(), FakePinFactor(), FakeLockoutClock()) + + assertEquals(WipePinChange.FAILED, empty.configureWipePin(WIPE.toCharArray())) + assertEquals(DuressPinChange.FAILED, empty.configureDuressPin(DURESS.toCharArray(), dek) { true }) + } + + @Test + fun wipeAndDuressPinsAreRefusedDuringALockout() { + repeat(LOCKOUT_AFTER) { auth.unlockWithPin(WRONG.toCharArray()) } + assertTrue(auth.remainingLockoutMillis() > 0) + + assertEquals(WipePinChange.FAILED, auth.configureWipePin(WIPE.toCharArray())) + assertEquals(DuressPinChange.FAILED, duress(DURESS)) + } + + @Test + fun aKeystoreFailureWhileSettingTheWipeOrDuressPinChangesNothing() { + val before = store.keys().associateWith { store.getBytes(it)!!.toList() } + factor.failing = true + + assertEquals(WipePinChange.FAILED, auth.configureWipePin(WIPE.toCharArray())) + assertEquals(DuressPinChange.FAILED, duress(DURESS)) + assertEquals(PinChange.FAILED, auth.changePin(NEW_PIN.toCharArray(), dek)) + + assertEquals(before, store.keys().associateWith { store.getBytes(it)!!.toList() }) + } + + @Test + fun aCorruptPinSlotRefusesTheWipeAndDuressPins() { + store.edit { putBytes("pin.slot", ByteArray(10)) } + + assertEquals(WipePinChange.FAILED, auth.configureWipePin(WIPE.toCharArray())) + assertEquals(DuressPinChange.FAILED, duress(DURESS)) + } + + @Test + fun aStoreThatCanNotBeWrittenRefusesTheNewWipePinAndPinChange() { + val failingStore = tempVaultStore() + val manager = AuthenticationManager(failingStore, FakePinFactor(), FakeLockoutClock()) + assertTrue(manager.configurePin(PIN.toCharArray(), dek)) + failingStore.beforeRename = { throw java.io.IOException("disk full") } + + assertEquals(WipePinChange.FAILED, manager.configureWipePin(WIPE.toCharArray())) + assertEquals(PinChange.FAILED, manager.changePin(NEW_PIN.toCharArray(), dek)) + } + + // --- the wipe's progress marker and the retired key ------------------------------------- + + @Test + fun withoutAMarkerNoWipeIsPending() { + assertNull(auth.pendingWipe()) + } + + @Test + fun anUnknownMarkerRedoesTheWholeErase() { + store.edit { putInt("wipe.pending", 99) } + + assertEquals(WipeStage.DATABASE, auth.pendingWipe()) + } + + @Test + fun theMarkerAdvancesToCleanupAndIsThenCleared() { + store.edit { putInt("wipe.pending", WipeStage.DATABASE.ordinal) } + assertEquals(WipeStage.DATABASE, auth.pendingWipe()) + + assertTrue(auth.markWipeDatabaseCreated()) + assertEquals(WipeStage.CLEANUP, auth.pendingWipe()) + + assertTrue(auth.clearPendingWipe()) + assertNull(auth.pendingWipe()) + } + + @Test + fun aStrayKeyInSlotBIsDeletedWhenNothingUsesIt() { + factor.ensureKey(PinKeySlot.B) + + auth.deleteStrayPinKey() + + assertFalse(factor.hasKey(PinKeySlot.B)) + assertTrue("the key every older build knows is never touched", factor.hasKey(PinKeySlot.A)) + } + + @Test + fun theKeyInSlotBIsKeptWhileAnEraseIsPending() { + factor.ensureKey(PinKeySlot.B) + store.edit { putInt("wipe.pending", WipeStage.DATABASE.ordinal) } + + auth.deleteStrayPinKey() + + assertTrue(factor.hasKey(PinKeySlot.B)) + } + + @Test + fun theKeyInSlotBIsKeptWhenSlotBIsTheActiveOne() { + factor.ensureKey(PinKeySlot.B) + store.edit { putInt("pin.key_slot", PinKeySlot.B.ordinal) } + + auth.deleteStrayPinKey() + assertTrue(factor.hasKey(PinKeySlot.B)) + + auth.deleteRetiredPinKey() + assertFalse("the retired key is the one in the other slot", factor.hasKey(PinKeySlot.A)) + assertTrue(factor.hasKey(PinKeySlot.B)) + } + + @Test + fun anUnknownKeySlotValueFallsBackToSlotA() { + store.edit { putInt("pin.key_slot", 7) } + + val unlock = auth.unlockWithPin(PIN.toCharArray()) + + assertTrue(unlock is PinUnlock.Success) + (unlock as PinUnlock.Success).dek.fill(0) + } + + private companion object { + const val PIN = "135790" + const val NEW_PIN = "246813" + const val WIPE = "864200" + const val DURESS = "975310" + const val WRONG = "000111" + const val LOCKOUT_AFTER = 5 + } +} diff --git a/app/src/test/java/com/pasich/encly/core/security/AutoLockTest.kt b/app/src/test/java/com/pasich/encly/core/security/AutoLockTest.kt index 29b6baa0..ca4c4a5b 100644 --- a/app/src/test/java/com/pasich/encly/core/security/AutoLockTest.kt +++ b/app/src/test/java/com/pasich/encly/core/security/AutoLockTest.kt @@ -22,7 +22,13 @@ class AutoLockTest { } @Test - fun noDelayIsLongerThanTwoMinutes() { - assertEquals(120_000L, AutoLockDelay.entries.maxOf { it.millis }) + fun longerDelaysPersistWithoutChangingTheDefault() { + val prefs = InMemorySharedPreferences() + AutoLock(prefs).setDelay(AutoLockDelay.MINUTES_15) + assertEquals(900_000L, AutoLock(prefs).delayMillis) + + AutoLock(prefs).setDelay(AutoLockDelay.MINUTES_30) + assertEquals(1_800_000L, AutoLock(prefs).delayMillis) + assertEquals(AutoLockDelay.SECONDS_15, AutoLock(InMemorySharedPreferences()).delay.value) } } diff --git a/app/src/test/java/com/pasich/encly/core/security/BiometricManagerTest.kt b/app/src/test/java/com/pasich/encly/core/security/BiometricManagerTest.kt new file mode 100644 index 00000000..2bbb09f8 --- /dev/null +++ b/app/src/test/java/com/pasich/encly/core/security/BiometricManagerTest.kt @@ -0,0 +1,188 @@ +package com.pasich.encly.core.security + +import android.app.Application +import android.content.Context +import androidx.fragment.app.FragmentActivity +import androidx.test.core.app.ApplicationProvider +import com.pasich.encly.testutil.tempVaultStore +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Robolectric +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.shadow.api.Shadow +import org.robolectric.shadows.ShadowBiometricManager +import android.hardware.biometrics.BiometricManager as PlatformBiometricManager + +/** + * The biometric slot's guard rails: nothing is ever released or enrolled without a strong + * sensor, a well-formed slot and the Keystore key behind it. The prompt itself needs a device. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class BiometricManagerTest { + private val context: Application get() = ApplicationProvider.getApplicationContext() + private val store = tempVaultStore() + private lateinit var manager: BiometricManager + + @Before + fun setUp() { + manager = BiometricManager(context, store) + } + + private fun sensor(available: Boolean) { + val platform = context.getSystemService(Context.BIOMETRIC_SERVICE) as PlatformBiometricManager + Shadow.extract(platform).setCanAuthenticate(available) + } + + private fun activity(): FragmentActivity = Robolectric.buildActivity(FragmentActivity::class.java).get() + + @Test + fun withoutASensorBiometricsAreUnavailable() { + sensor(false) + + assertFalse(manager.isStrongBiometricAvailable()) + assertEquals(BiometricStatus.UNAVAILABLE, manager.strongBiometricStatus()) + } + + @Test + fun aUsableStrongSensorIsAvailable() { + sensor(true) + + assertTrue(manager.isStrongBiometricAvailable()) + assertEquals(BiometricStatus.AVAILABLE, manager.strongBiometricStatus()) + } + + @Test + fun withoutAStoredSlotThereIsNoSlot() { + assertFalse(manager.hasSlot()) + } + + @Test + fun aStoredSlotWithoutItsKeystoreKeyIsNotASlot() { + store.edit { putBytes("bio.slot", ByteArray(60)) } + + assertFalse(manager.hasSlot()) + } + + @Test + fun enrollingWithoutAStrongSensorFailsAndStoresNothing() { + sensor(false) + var result: Boolean? = null + + manager.enroll(activity(), ByteArray(32)) { result = it } + + assertEquals(false, result) + assertFalse(store.contains("bio.slot")) + } + + @Test + fun enrollingAKeyOfTheWrongSizeFailsBeforeAnythingIsTouched() { + sensor(true) + store.edit { putBytes("bio.slot", ByteArray(60)) } + val results = mutableListOf() + + manager.enroll(activity(), ByteArray(31)) { results += it } + manager.enroll(activity(), ByteArray(33)) { results += it } + + assertEquals(listOf(false, false), results) + assertTrue("the existing slot is left alone", store.contains("bio.slot")) + } + + @Test + fun unlockingWithoutASlotYieldsNothing() { + sensor(true) + var called = false + var dek: ByteArray? = byteArrayOf(1) + + manager.unlock(activity()) { + called = true + dek = it + } + + assertTrue(called) + assertNull(dek) + } + + @Test + fun unlockingWithoutAStrongSensorYieldsNothingAndKeepsTheSlot() { + sensor(false) + store.edit { putBytes("bio.slot", ByteArray(60)) } + var dek: ByteArray? = byteArrayOf(1) + + manager.unlock(activity()) { dek = it } + + assertNull(dek) + assertTrue(store.contains("bio.slot")) + } + + @Test + fun aTruncatedSlotNeverReachesThePrompt() { + sensor(true) + store.edit { putBytes("bio.slot", ByteArray(12)) } + var dek: ByteArray? = byteArrayOf(1) + + manager.unlock(activity()) { dek = it } + + assertNull(dek) + } + + @Test + fun aSlotWhoseKeystoreKeyIsGoneUnlocksNothing() { + sensor(true) + store.edit { putBytes("bio.slot", ByteArray(60)) } + var called = false + var dek: ByteArray? = byteArrayOf(1) + + manager.unlock(activity()) { + called = true + dek = it + } + + assertTrue("the caller is always answered", called) + assertNull(dek) + } + + @Test + fun authenticatingWithoutAStrongSensorReportsAnErrorInsteadOfPrompting() { + sensor(false) + val events = mutableListOf() + val callback = object : BiometricManager.BiometricCallback { + override fun onSuccess() { + events += "success" + } + + override fun onError(errorCode: Int, errorMessage: String) { + events += "error:$errorCode" + } + + override fun onFailed() { + events += "failed" + } + } + + manager.authenticate(activity(), BiometricManager.BiometricType.GENERAL, callback) + + assertEquals(listOf("error:-1"), events) + } + + @Test + fun disablingDropsEveryBiometricEntryAndNothingElse() { + store.edit { + putBytes("bio.slot", ByteArray(60)) + putInt("bio.extra", 1) + putInt("pin.keep", 2) + } + + manager.disable() + + assertFalse(store.contains("bio.slot")) + assertFalse(store.contains("bio.extra")) + assertEquals(2, store.getInt("pin.keep", 0)) + } +} diff --git a/app/src/test/java/com/pasich/encly/core/security/DuressPinTest.kt b/app/src/test/java/com/pasich/encly/core/security/DuressPinTest.kt new file mode 100644 index 00000000..c7e06e27 --- /dev/null +++ b/app/src/test/java/com/pasich/encly/core/security/DuressPinTest.kt @@ -0,0 +1,610 @@ +package com.pasich.encly.core.security + +import com.pasich.encly.data.backup.BackupManager +import com.pasich.encly.data.database.SecureDatabaseManager +import com.pasich.encly.data.database.VaultFile +import com.pasich.encly.testutil.FakeLockoutClock +import com.pasich.encly.testutil.FakePinFactor +import com.pasich.encly.testutil.InMemorySharedPreferences +import com.pasich.encly.testutil.anyByteArray +import com.pasich.encly.testutil.eqValue +import com.pasich.encly.testutil.tempVaultStore +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.mockito.ArgumentMatchers.anyBoolean +import org.mockito.ArgumentMatchers.eq +import org.mockito.Mockito.mock +import org.mockito.Mockito.never +import org.mockito.Mockito.verify +import org.mockito.Mockito.`when` + +/** + * The duress PIN (issue #61): a third PIN that, typed on the lock screen, opens a decoy vault of + * its own (separate DEK, separate database) and never touches the real one. + */ +class DuressPinTest { + private lateinit var store: VaultStore + private lateinit var factor: FakePinFactor + private lateinit var auth: AuthenticationManager + private lateinit var clock: FakeLockoutClock + private val dek = ByteArray(DEK_LENGTH) { 0x3C } + + @Before + fun setUp() { + store = tempVaultStore() + factor = FakePinFactor() + clock = FakeLockoutClock() + auth = AuthenticationManager(store, factor, clock) + assertTrue(auth.configurePin(pin(PIN), dek)) + } + + // --- nothing shows whether a duress PIN is set ---------------------------------------- + + @Test + fun theStoreHasTheSameKeysAndSizesWithAndWithoutADuressPin() { + val otherStore = tempVaultStore() + val other = AuthenticationManager(otherStore, FakePinFactor(), FakeLockoutClock()) + assertTrue(other.configurePin(pin(PIN), dek)) + assertEquals(DuressPinChange.SET_NEW_VAULT, other.configureDuressPin(pin(DURESS_PIN), dek) { true }) + + assertEquals(shape(store), shape(otherStore)) + assertTrue(store.contains(DECOY_SLOT) && store.contains(DECOY_KEY)) + } + + @Test + fun anExistingVaultGetsRandomDuressEntriesWithoutTouchingThePinSlot() { + store.edit { + remove(DECOY_SLOT) + remove(DECOY_KEY) + } + val before = store.getBytes(PIN_SLOT)!! + + assertTrue(auth.ensureWipeSlot()) + val slot = store.getBytes(DECOY_SLOT)!! + assertTrue(auth.ensureWipeSlot()) + + assertArrayEquals(before, store.getBytes(PIN_SLOT)) + assertArrayEquals("a second run writes nothing", slot, store.getBytes(DECOY_SLOT)) + assertFalse(auth.hasDecoyVault(dek)) + assertEquals(PinUnlock.WrongPin, auth.unlockWithPin(pin(DURESS_PIN))) + } + + @Test + fun onlyTheRealVaultsSessionCanTellADecoyVaultExists() { + assertFalse(auth.hasDecoyVault(dek)) + assertEquals(DuressPinChange.SET_NEW_VAULT, auth.configureDuressPin(pin(DURESS_PIN), dek) { true }) + + assertTrue(auth.hasDecoyVault(dek)) + val decoyDek = (auth.unlockWithPin(pin(DURESS_PIN)) as PinUnlock.Decoy).dek + assertFalse("the decoy DEK does not reveal it", auth.hasDecoyVault(decoyDek)) + } + + // --- dispatch ----------------------------------------------------------------------- + + @Test + fun eachPinOpensItsOwnVault() { + assertEquals(WipePinChange.SET, auth.configureWipePin(pin(WIPE_PIN))) + assertEquals(DuressPinChange.SET_NEW_VAULT, auth.configureDuressPin(pin(DURESS_PIN), dek) { true }) + + assertArrayEquals(dek, (auth.unlockWithPin(pin(PIN)) as PinUnlock.Success).dek) + val decoyDek = (auth.unlockWithPin(pin(DURESS_PIN)) as PinUnlock.Decoy).dek + assertFalse(decoyDek.contentEquals(dek)) + assertArrayEquals( + "the same decoy vault every time", + decoyDek, + (auth.unlockWithPin(pin(DURESS_PIN)) as PinUnlock.Decoy).dek, + ) + assertEquals(PinUnlock.WrongPin, auth.unlockWithPin(pin("000000"))) + // The duress PIN changed nothing: the real vault and the wipe PIN are as they were. + assertArrayEquals(dek, (auth.unlockWithPin(pin(PIN)) as PinUnlock.Success).dek) + assertTrue(auth.unlockWithPin(pin(WIPE_PIN)) is PinUnlock.Erased) + } + + @Test + fun everyAttemptRunsTheHardwareHalfOnce() { + assertEquals(DuressPinChange.SET_NEW_VAULT, auth.configureDuressPin(pin(DURESS_PIN), dek) { true }) + listOf(PIN, DURESS_PIN, "000000").forEach { + val before = factor.calls + auth.unlockWithPin(pin(it)) + assertEquals("one KDF run for $it", before + 1, factor.calls) + } + } + + @Test + fun theDuressPinIsCountedAndThenClearsTheLockoutLikeTheRightPin() { + assertEquals(DuressPinChange.SET_NEW_VAULT, auth.configureDuressPin(pin(DURESS_PIN), dek) { true }) + repeat(AuthenticationManager.MAX_ATTEMPTS - 1) { auth.unlockWithPin(pin("000000")) } + assertTrue(store.getInt(FAILURES, 0) > 0) + + assertTrue(auth.unlockWithPin(pin(DURESS_PIN)) is PinUnlock.Decoy) + + assertEquals(0, store.getInt(FAILURES, 0)) + assertEquals( + "the duress PIN itself is no strike", + AuthenticationManager.MAX_ATTEMPTS - 1, + store.getInt(STRIKES, 0), + ) + } + + @Test + fun theDuressPinBetweenGuessesNeverBuysUnlimitedGuessesAtThePin() { + assertEquals(DuressPinChange.SET_NEW_VAULT, auth.configureDuressPin(pin(DURESS_PIN), dek) { true }) + val free = AuthenticationManager.MAX_ATTEMPTS - 1 + var guesses = 0 + var locked = false + // Four wrong guesses, the duress PIN, again: the coercer's loop. + while (!locked && guesses < 100) { + repeat(free) { + if (auth.unlockWithPin(pin("000000")) == PinUnlock.LockedOut) locked = true else guesses++ + } + if (!locked) assertTrue(auth.unlockWithPin(pin(DURESS_PIN)) is PinUnlock.Decoy) + } + + assertEquals( + "free guesses end after the grace", + AuthenticationManager.MAX_ATTEMPTS + AuthenticationManager.STRIKE_GRACE, + guesses, + ) + // Past the grace every miss is locked out, even right after the duress PIN, and longer each time. + var lastPenalty = 0L + repeat(3) { + clock.elapsed += auth.remainingLockoutMillis() + assertTrue(auth.unlockWithPin(pin(DURESS_PIN)) is PinUnlock.Decoy) + assertEquals(PinUnlock.WrongPin, auth.unlockWithPin(pin("000000"))) + val penalty = auth.remainingLockoutMillis() + assertTrue("locked out at once: $penalty", penalty > lastPenalty) + lastPenalty = penalty + } + } + + @Test + fun theRightPinStartsTheStrikesOver() { + assertEquals(DuressPinChange.SET_NEW_VAULT, auth.configureDuressPin(pin(DURESS_PIN), dek) { true }) + repeat(2) { + repeat(AuthenticationManager.MAX_ATTEMPTS - 1) { auth.unlockWithPin(pin("000000")) } + assertTrue(auth.unlockWithPin(pin(DURESS_PIN)) is PinUnlock.Decoy) + } + assertTrue(store.getInt(STRIKES, 0) > AuthenticationManager.MAX_ATTEMPTS) + + assertTrue(auth.unlockWithPin(pin(PIN)) is PinUnlock.Success) + + assertFalse(store.contains(STRIKES)) + repeat(AuthenticationManager.MAX_ATTEMPTS - 1) { auth.unlockWithPin(pin("000000")) } + assertEquals(0L, auth.remainingLockoutMillis()) + } + + @Test + fun aHardwareFailureGivesTheStrikeBackAndAKillMidCheckKeepsIt() { + repeat(2) { auth.unlockWithPin(pin("000000")) } + factor.failing = true + + assertEquals(PinUnlock.Failed, auth.unlockWithPin(pin("000000"))) + + assertEquals(2, store.getInt(STRIKES, 0)) + factor.failing = false + // Killed during the KDF: the strike was written before it ran and is never refunded. + val killing = AuthenticationManager( + store, + object : PinHardwareFactor by factor { + override fun mac(slot: PinKeySlot, data: ByteArray): ByteArray = error("killed") + }, + clock, + ) + runCatching { killing.unlockWithPin(pin("000000")) } + assertEquals(3, store.getInt(STRIKES, 0)) + } + + @Test + fun refusingAnotherPinInSettingsCostsAMissAndALockoutRefusesIt() { + assertEquals(DuressPinChange.SET_NEW_VAULT, auth.configureDuressPin(pin(DURESS_PIN), dek) { true }) + + assertEquals(WipePinChange.IN_USE, auth.configureWipePin(pin(DURESS_PIN))) + assertEquals(WipePinChange.SAME_AS_PIN, auth.configureWipePin(pin(PIN))) + assertEquals(PinChange.IN_USE, auth.changePin(pin(DURESS_PIN), dek)) + assertEquals(DuressPinChange.SAME_AS_PIN, auth.configureDuressPin(pin(PIN), dek) { true }) + assertEquals(4, store.getInt(FAILURES, 0)) + + assertEquals(WipePinChange.IN_USE, auth.configureWipePin(pin(DURESS_PIN))) + assertTrue(auth.remainingLockoutMillis() > 0) + assertEquals(WipePinChange.FAILED, auth.configureWipePin(pin(DURESS_PIN))) + assertEquals(DuressPinChange.FAILED, auth.configureDuressPin(pin(OTHER_DURESS_PIN), dek) { true }) + assertEquals(PinUnlock.LockedOut, auth.unlockWithPin(pin(PIN))) + } + + @Test + fun aRunningLockoutRefusesTheDuressPin() { + assertEquals(DuressPinChange.SET_NEW_VAULT, auth.configureDuressPin(pin(DURESS_PIN), dek) { true }) + repeat(AuthenticationManager.MAX_ATTEMPTS) { auth.unlockWithPin(pin("000000")) } + + assertEquals(PinUnlock.LockedOut, auth.unlockWithPin(pin(DURESS_PIN))) + } + + @Test + fun aDecoySessionVerifiesOnlyTheDuressPinAndTheRealSessionOnlyThePin() { + assertEquals(DuressPinChange.SET_NEW_VAULT, auth.configureDuressPin(pin(DURESS_PIN), dek) { true }) + + assertTrue(auth.verifyPinAuth(pin(PIN))) + assertFalse(auth.verifyPinAuth(pin(DURESS_PIN))) + assertTrue(auth.verifyPinAuth(pin(DURESS_PIN), decoy = true)) + assertFalse("no oracle for the real PIN", auth.verifyPinAuth(pin(PIN), decoy = true)) + } + + // --- collisions --------------------------------------------------------------------- + + @Test + fun theDuressPinMustDifferFromThePinAndTheWipePin() { + assertEquals(WipePinChange.SET, auth.configureWipePin(pin(WIPE_PIN))) + + assertEquals(DuressPinChange.SAME_AS_PIN, auth.configureDuressPin(pin(PIN), dek) { true }) + assertEquals(DuressPinChange.IN_USE, auth.configureDuressPin(pin(WIPE_PIN), dek) { true }) + assertEquals(DuressPinChange.FAILED, auth.configureDuressPin(pin("12345"), dek) { true }) + assertFalse(auth.hasDecoyVault(dek)) + assertTrue("the wipe PIN still wipes", auth.unlockWithPin(pin(WIPE_PIN)) is PinUnlock.Erased) + } + + @Test + fun theWipePinMayNotBeTheDuressPin() { + assertEquals(DuressPinChange.SET_NEW_VAULT, auth.configureDuressPin(pin(DURESS_PIN), dek) { true }) + + assertEquals(WipePinChange.IN_USE, auth.configureWipePin(pin(DURESS_PIN))) + assertTrue(auth.unlockWithPin(pin(DURESS_PIN)) is PinUnlock.Decoy) + } + + @Test + fun changingThePinToTheDuressPinIsRefusedAndKeepsBoth() { + assertEquals(DuressPinChange.SET_NEW_VAULT, auth.configureDuressPin(pin(DURESS_PIN), dek) { true }) + + assertEquals(PinChange.IN_USE, auth.changePin(pin(DURESS_PIN), dek)) + + assertArrayEquals(dek, (auth.unlockWithPin(pin(PIN)) as PinUnlock.Success).dek) + assertTrue(auth.unlockWithPin(pin(DURESS_PIN)) is PinUnlock.Decoy) + } + + @Test + fun changingThePinKeepsTheDuressPin() { + assertEquals(DuressPinChange.SET_NEW_VAULT, auth.configureDuressPin(pin(DURESS_PIN), dek) { true }) + val decoyDek = (auth.unlockWithPin(pin(DURESS_PIN)) as PinUnlock.Decoy).dek + + assertEquals(PinChange.SET, auth.changePin(pin(NEW_PIN), dek)) + + assertArrayEquals(decoyDek, (auth.unlockWithPin(pin(DURESS_PIN)) as PinUnlock.Decoy).dek) + assertArrayEquals(dek, (auth.unlockWithPin(pin(NEW_PIN)) as PinUnlock.Success).dek) + assertFalse(auth.duressPinTurnedOff(dek)) + } + + @Test + fun aPinKeyResetTurnsTheDuressPinOffSaysSoAndSettingItAgainKeepsTheNotes() { + assertEquals(DuressPinChange.SET_NEW_VAULT, auth.configureDuressPin(pin(DURESS_PIN), dek) { true }) + val decoyDek = (auth.unlockWithPin(pin(DURESS_PIN)) as PinUnlock.Decoy).dek + factor.lost = true + + assertTrue(auth.configurePin(pin(NEW_PIN), dek)) + + assertTrue(auth.duressPinTurnedOff(dek)) + assertEquals(PinUnlock.WrongPin, auth.unlockWithPin(pin(DURESS_PIN))) + var newVault = false + assertEquals( + DuressPinChange.SET, + auth.configureDuressPin(pin(DURESS_PIN), dek) { + newVault = true + true + }, + ) + assertFalse("the decoy database is kept", newVault) + assertFalse(auth.duressPinTurnedOff(dek)) + assertArrayEquals(decoyDek, (auth.unlockWithPin(pin(DURESS_PIN)) as PinUnlock.Decoy).dek) + } + + @Test + fun aResetWithoutADecoyVaultSaysNothing() { + factor.lost = true + + assertTrue(auth.configurePin(pin(NEW_PIN), dek)) + + assertFalse(auth.duressPinTurnedOff(dek)) + } + + @Test + fun aPinSetWithANewSaltTurnsTheDuressPinOffAndSettingsSaysSo() { + assertEquals(DuressPinChange.SET_NEW_VAULT, auth.configureDuressPin(pin(DURESS_PIN), dek) { true }) + // The PIN slot is unreadable (recovery phrase unlock, then a new PIN): a new salt. + store.edit { remove(PIN_SLOT) } + + assertTrue(auth.configurePin(pin(NEW_PIN), dek)) + + assertEquals(PinUnlock.WrongPin, auth.unlockWithPin(pin(DURESS_PIN))) + assertTrue(auth.hasDecoyVault(dek)) + assertTrue(auth.duressPinTurnedOff(dek)) + assertEquals(DuressPinChange.SET, auth.configureDuressPin(pin(DURESS_PIN), dek) { true }) + assertFalse(auth.duressPinTurnedOff(dek)) + } + + @Test + fun everyLifecycleStepLeavesTheSameStoreShapeWithAndWithoutADecoyVault() { + val plain = tempVaultStore() + val plainFactor = FakePinFactor() + val other = AuthenticationManager(plain, plainFactor, FakeLockoutClock()) + assertTrue(other.configurePin(pin(PIN), dek)) + assertEquals(DuressPinChange.SET_NEW_VAULT, auth.configureDuressPin(pin(DURESS_PIN), dek) { true }) + fun same(step: String) = assertEquals(step, shape(plain), shape(store)) + same("set") + + assertEquals(PinChange.SET, other.changePin(pin(NEW_PIN), dek)) + assertEquals(PinChange.SET, auth.changePin(pin(NEW_PIN), dek)) + same("PIN change") + + plainFactor.lost = true + factor.lost = true + assertTrue(other.configurePin(pin(PIN), dek)) + assertTrue(auth.configurePin(pin(PIN), dek)) + same("key reset") + + plain.edit { remove(PIN_SLOT) } + store.edit { remove(PIN_SLOT) } + assertTrue(other.configurePin(pin(NEW_PIN), dek)) + assertTrue(auth.configurePin(pin(NEW_PIN), dek)) + same("new salt") + + assertTrue(other.removeDuressPin()) + assertTrue(auth.removeDuressPin()) + same("removed") + + assertEquals(WipePinChange.SET, other.configureWipePin(pin(WIPE_PIN))) + assertEquals(WipePinChange.SET, auth.configureWipePin(pin(WIPE_PIN))) + assertTrue(other.unlockWithPin(pin(WIPE_PIN)) is PinUnlock.Erased) + assertTrue(auth.unlockWithPin(pin(WIPE_PIN)) is PinUnlock.Erased) + same("erase") + } + + @Test + fun aNewDecoyVaultFirstClearsWhatAnEarlierOneLeftAndStopsIfThatFails() { + assertEquals(DuressPinChange.FAILED, auth.configureDuressPin(pin(DURESS_PIN), dek) { false }) + assertEquals(PinUnlock.WrongPin, auth.unlockWithPin(pin(DURESS_PIN))) + + var cleared = 0 + assertEquals( + DuressPinChange.SET_NEW_VAULT, + auth.configureDuressPin(pin(DURESS_PIN), dek) { + cleared++ + true + }, + ) + assertEquals( + DuressPinChange.SET, + auth.configureDuressPin(pin(OTHER_DURESS_PIN), dek) { + cleared++ + true + }, + ) + assertEquals(1, cleared) + assertEquals(PinUnlock.WrongPin, auth.unlockWithPin(pin(DURESS_PIN))) + assertTrue(auth.unlockWithPin(pin(OTHER_DURESS_PIN)) is PinUnlock.Decoy) + } + + @Test + fun removingTheDuressPinMakesItAWrongPinAndKeepsTheShape() { + assertEquals(DuressPinChange.SET_NEW_VAULT, auth.configureDuressPin(pin(DURESS_PIN), dek) { true }) + val before = shape(store) + + assertTrue(auth.removeDuressPin()) + + assertEquals(before, shape(store)) + assertFalse(auth.hasDecoyVault(dek)) + assertEquals(PinUnlock.WrongPin, auth.unlockWithPin(pin(DURESS_PIN))) + assertArrayEquals(dek, (auth.unlockWithPin(pin(PIN)) as PinUnlock.Success).dek) + } + + @Test + fun theWipePinAlsoErasesTheDecoyVault() { + assertEquals(WipePinChange.SET, auth.configureWipePin(pin(WIPE_PIN))) + assertEquals(DuressPinChange.SET_NEW_VAULT, auth.configureDuressPin(pin(DURESS_PIN), dek) { true }) + + val erased = auth.unlockWithPin(pin(WIPE_PIN)) as PinUnlock.Erased + + assertFalse(auth.hasDecoyVault(erased.dek)) + assertFalse(auth.hasDecoyVault(dek)) + assertEquals(PinUnlock.WrongPin, auth.unlockWithPin(pin(DURESS_PIN))) + assertTrue(store.contains(DECOY_SLOT) && store.contains(DECOY_KEY)) + } + + // --- the session -------------------------------------------------------------------- + + @Test + fun theDuressPinOpensTheDecoyDatabaseAndNeverTheRealOne() { + val vault = Vault() + `when`(vault.database.unlockDatabase(anyByteArray(), anyBoolean(), eqValue(VaultFile.DECOY))).thenReturn(true) + + assertEquals(VaultUnlockResult.SUCCESS, vault.security.unlockWithPin(pin(DURESS_PIN))) + + verify(vault.database).unlockDatabase(anyByteArray(), eq(true), eqValue(VaultFile.DECOY)) + verify(vault.database, never()).unlockDatabase(anyByteArray(), anyBoolean()) + verify(vault.database, never()).wipe() + assertTrue(vault.security.isDecoySession()) + } + + @Test + fun anExistingDecoyDatabaseIsOpenedNotCreated() { + val vault = Vault() + `when`(vault.database.hasEncryptedDatabase(VaultFile.DECOY)).thenReturn(true) + `when`(vault.database.unlockDatabase(anyByteArray(), anyBoolean(), eqValue(VaultFile.DECOY))).thenReturn(true) + + assertEquals(VaultUnlockResult.SUCCESS, vault.security.unlockWithPin(pin(DURESS_PIN))) + + verify(vault.database).unlockDatabase(anyByteArray(), eq(false), eqValue(VaultFile.DECOY)) + } + + @Test + fun aDecoyDatabaseThatDoesNotOpenFailsWithoutFallingBack() { + val vault = Vault() + `when`(vault.database.hasEncryptedDatabase(VaultFile.DECOY)).thenReturn(true) + `when`(vault.database.unlockDatabase(anyByteArray(), anyBoolean(), eqValue(VaultFile.DECOY))).thenReturn(false) + + assertEquals(VaultUnlockResult.DB_ERROR, vault.security.unlockWithPin(pin(DURESS_PIN))) + + verify(vault.database, never()).unlockDatabase(anyByteArray(), anyBoolean()) + verify(vault.database, never()).wipeIfClosed(VaultFile.DECOY) + assertFalse(vault.security.isDecoySession()) + assertFalse(vault.security.isDatabaseUnlocked()) + } + + @Test + fun aDecoySessionCanNotTouchTheRealVaultsKeys() { + val vault = Vault() + `when`(vault.database.unlockDatabase(anyByteArray(), anyBoolean(), eqValue(VaultFile.DECOY))).thenReturn(true) + assertEquals(VaultUnlockResult.SUCCESS, vault.security.unlockWithPin(pin(DURESS_PIN))) + val before = vault.store.keys().associateWith { vault.store.getBytes(it)!!.toList() } + + assertEquals(PinChange.FAILED, vault.security.changePin(pin(NEW_PIN))) + assertFalse(vault.security.configurePin(pin(NEW_PIN))) + assertEquals(WipePinChange.FAILED, vault.security.configureWipePin(pin(WIPE_PIN))) + assertFalse(vault.security.removeWipePin()) + assertEquals(DuressPinChange.FAILED, vault.security.configureDuressPin(pin(OTHER_DURESS_PIN))) + assertFalse(vault.security.removeDuressPin()) + assertFalse(vault.security.hasDuressPin()) + assertNull(vault.security.copyBackupRootKey()) + assertFalse(vault.security.addRecoverySeed(vault.seed.generateMnemonic().chars)) + assertFalse(vault.security.replaceRecoverySeed(vault.seed.generateMnemonic().chars)) + assertFalse(vault.security.createBackupKey(vault.seed.generateMnemonic().chars)) + assertFalse(vault.security.canResetPinWithoutCurrent()) + vault.security.disableBiometric() + assertFalse(vault.security.wipeAndReset()) + + verify(vault.biometric, never()).disable() + verify(vault.database, never()).wipe() + assertEquals(before, vault.store.keys().associateWith { vault.store.getBytes(it)!!.toList() }) + assertTrue(vault.security.isDecoySession()) + } + + @Test + fun aDecoySessionReChecksWithTheDuressPin() { + val vault = Vault() + `when`(vault.database.unlockDatabase(anyByteArray(), anyBoolean(), eqValue(VaultFile.DECOY))).thenReturn(true) + assertEquals(VaultUnlockResult.SUCCESS, vault.security.unlockWithPin(pin(DURESS_PIN))) + + assertTrue(vault.security.verifyPin(pin(DURESS_PIN))) + assertFalse(vault.security.verifyPin(pin(PIN))) + } + + @Test + fun switchingVaultsChangesTheEpochButReopeningTheSameOneDoesNot() { + val vault = Vault() + `when`(vault.database.unlockDatabase(anyByteArray(), anyBoolean())).thenReturn(true) + `when`(vault.database.unlockDatabase(anyByteArray(), anyBoolean(), eqValue(VaultFile.DECOY))).thenReturn(true) + + assertEquals(VaultUnlockResult.SUCCESS, vault.security.unlockWithPin(pin(PIN))) + val real = vault.security.eraseEpoch() + vault.security.lock() + assertEquals(VaultUnlockResult.SUCCESS, vault.security.unlockWithPin(pin(PIN))) + assertEquals(real, vault.security.eraseEpoch()) + vault.security.lock() + + assertEquals(VaultUnlockResult.SUCCESS, vault.security.unlockWithPin(pin(DURESS_PIN))) + val decoy = vault.security.eraseEpoch() + assertNotEquals(real, decoy) + assertFalse(vault.security.getSettingsAuth().hasDuressPin) + vault.security.lock() + + assertEquals(VaultUnlockResult.SUCCESS, vault.security.unlockWithPin(pin(PIN))) + assertNotEquals(decoy, vault.security.eraseEpoch()) + assertFalse(vault.security.isDecoySession()) + assertTrue(vault.security.getSettingsAuth().hasDuressPin) + } + + @Test + fun removingTheDuressPinDeletesOnlyTheDecoyDatabase() { + val vault = Vault() + `when`(vault.database.unlockDatabase(anyByteArray(), anyBoolean())).thenReturn(true) + `when`(vault.database.wipeIfClosed(VaultFile.DECOY)).thenReturn(true) + assertFalse("locked", vault.security.removeDuressPin()) + assertEquals(VaultUnlockResult.SUCCESS, vault.security.unlockWithPin(pin(PIN))) + + assertTrue(vault.security.removeDuressPin()) + + verify(vault.database).wipeIfClosed(VaultFile.DECOY) + verify(vault.database, never()).wipe() + assertFalse(vault.security.hasDuressPin()) + assertEquals(VaultUnlockResult.INVALID_CREDENTIAL, vault.security.unlockWithPin(pin(DURESS_PIN))) + } + + @Test + fun settingTheDuressPinWipesTheTypedPin() { + val vault = Vault(withDuressPin = false) + `when`(vault.database.unlockDatabase(anyByteArray(), anyBoolean())).thenReturn(true) + `when`(vault.database.wipeIfClosed(VaultFile.DECOY)).thenReturn(true) + assertEquals(DuressPinChange.FAILED, vault.security.configureDuressPin(pin(DURESS_PIN))) + assertEquals(VaultUnlockResult.SUCCESS, vault.security.unlockWithPin(pin(PIN))) + + val typed = pin(DURESS_PIN) + assertEquals(DuressPinChange.SET_NEW_VAULT, vault.security.configureDuressPin(typed)) + + assertTrue(typed.all { it == '\u0000' }) + verify(vault.database).wipeIfClosed(VaultFile.DECOY) + assertTrue(vault.security.hasDuressPin()) + } + + @Test + fun aDecoyCanNotOpenWhileAnEraseIsPending() { + val vault = Vault() + `when`(vault.database.unlockDatabase(anyByteArray(), anyBoolean(), eqValue(VaultFile.DECOY))).thenReturn(true) + vault.store.edit { putInt("wipe.pending", WipeStage.DATABASE.ordinal) } + + assertEquals(VaultUnlockResult.DB_ERROR, vault.security.unlockWithPin(pin(DURESS_PIN))) + + verify(vault.database, never()).unlockDatabase(anyByteArray(), anyBoolean(), eqValue(VaultFile.DECOY)) + } + + /** A committed vault with a recovery phrase, the PIN and (by default) a duress PIN. */ + private inner class Vault(withDuressPin: Boolean = true) { + val store = this@DuressPinTest.store + val auth = this@DuressPinTest.auth + val prefs = InMemorySharedPreferences() + val seed = SeedPhraseManager(store) + val database: SecureDatabaseManager = mock(SecureDatabaseManager::class.java) + val biometric: BiometricManager = mock(BiometricManager::class.java) + val security = SecurityManager(prefs, store, seed, database, auth, biometric) {} + + init { + assertTrue(seed.initializeVault(seed.generateMnemonic().chars)) + val bootstrap = seed.copyBootstrapKey()!! + assertTrue(auth.configurePin(pin(PIN), bootstrap)) + if (withDuressPin) { + assertEquals( + DuressPinChange.SET_NEW_VAULT, + auth.configureDuressPin(pin(DURESS_PIN), bootstrap) { + true + }, + ) + } + prefs.edit() + .putBoolean("onboarding_shown_v3", true) + .putLong(BackupManager.LAST_EXPORT_KEY, 1L) + .commit() + } + } + + /** Every key and the length of its value. */ + private fun shape(store: VaultStore): Map = + store.keys().toSortedSet().associateWith { store.getBytes(it)!!.size } + + private fun pin(value: String) = value.toCharArray() + + private companion object { + const val PIN = "135790" + const val NEW_PIN = "246813" + const val WIPE_PIN = "864200" + const val DURESS_PIN = "975310" + const val OTHER_DURESS_PIN = "112358" + const val DEK_LENGTH = 32 + const val PIN_SLOT = "pin.slot" + const val DECOY_SLOT = "pin.decoy.slot" + const val DECOY_KEY = "decoy.key" + const val FAILURES = "lockout.failures" + const val STRIKES = "pin.strikes" + } +} diff --git a/app/src/test/java/com/pasich/encly/core/security/KeystorePinFactorTest.kt b/app/src/test/java/com/pasich/encly/core/security/KeystorePinFactorTest.kt new file mode 100644 index 00000000..c64e0a19 --- /dev/null +++ b/app/src/test/java/com/pasich/encly/core/security/KeystorePinFactorTest.kt @@ -0,0 +1,210 @@ +package com.pasich.encly.core.security + +import android.security.keystore.KeyGenParameterSpec +import com.pasich.encly.testutil.FakeAndroidKeyStore +import org.junit.After +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertTrue +import org.junit.Assert.fail +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.security.KeyStoreException +import java.security.ProviderException +import java.security.UnrecoverableKeyException + +/** + * The Keystore-backed PIN factor against an in-memory Keystore that fails on demand: what it + * generates, which failures mean "the key is gone for good" and which may pass, and that a + * failed generation never leaves a half-made key behind. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class KeystorePinFactorTest { + private val factor = KeystorePinFactor() + private val aliasA = "encly_pin_factor_v3" + private val aliasB = "encly_pin_factor_v3b" + + @Before + fun setUp() = FakeAndroidKeyStore.install() + + @After + fun tearDown() = FakeAndroidKeyStore.uninstall() + + private fun failure(block: () -> Unit): PinFactorException { + try { + block() + } catch (e: PinFactorException) { + return e + } + fail("expected a PinFactorException") + error("unreachable") + } + + @Test + fun theFirstCallCreatesTheKeyAndLaterCallsKeepIt() { + assertTrue(factor.ensureKey(PinKeySlot.A)) + val first = factor.mac(PinKeySlot.A, byteArrayOf(1, 2, 3)) + + assertFalse(factor.ensureKey(PinKeySlot.A)) + assertArrayEquals(first, factor.mac(PinKeySlot.A, byteArrayOf(1, 2, 3))) + assertEquals(32, first.size) + } + + @Test + fun theTwoSlotsKeepTheirOwnKeysUnderTheirOwnAliases() { + factor.ensureKey(PinKeySlot.A) + factor.ensureKey(PinKeySlot.B) + + assertEquals(setOf(aliasA, aliasB), FakeAndroidKeyStore.aliases()) + assertFalse(factor.mac(PinKeySlot.A, byteArrayOf(9)).contentEquals(factor.mac(PinKeySlot.B, byteArrayOf(9)))) + } + + @Test + fun resettingReplacesTheKeySoOldMacsNoLongerMatch() { + factor.ensureKey(PinKeySlot.A) + val before = factor.mac(PinKeySlot.A, byteArrayOf(1)) + + factor.reset(PinKeySlot.A) + + assertFalse(before.contentEquals(factor.mac(PinKeySlot.A, byteArrayOf(1)))) + } + + @Test + fun deletingAKeyRemovesItAndMacingWithoutOneIsALostKey() { + factor.ensureKey(PinKeySlot.A) + + factor.delete(PinKeySlot.A) + + assertFalse(FakeAndroidKeyStore.has(aliasA)) + assertTrue(failure { factor.mac(PinKeySlot.A, byteArrayOf(1)) }.lost) + assertTrue("a key deleted behind our back is reported as newly created", factor.ensureKey(PinKeySlot.A)) + } + + @Test + fun deletingAMissingKeyIsHarmless() { + factor.delete(PinKeySlot.B) + + assertTrue(FakeAndroidKeyStore.aliases().isEmpty()) + } + + @Test + fun strongBoxIsTriedFirstAndRequiresAnUnlockedDevice() { + factor.ensureKey(PinKeySlot.A) + + val spec = FakeAndroidKeyStore.specs.single() + assertTrue(spec.isStrongBoxBacked) + assertTrue(spec.isUnlockedDeviceRequired) + assertEquals(256, spec.keySize) + assertEquals(android.security.keystore.KeyProperties.PURPOSE_SIGN, spec.purposes) + } + + @Test + fun aDeviceWithoutStrongBoxFallsBackToTheTeeStillRequiringAnUnlockedDevice() { + FakeAndroidKeyStore.onGenerate = { if (it.isStrongBoxBacked) throw ProviderException("no StrongBox") } + + assertTrue(factor.ensureKey(PinKeySlot.A)) + + val specs = FakeAndroidKeyStore.specs + assertEquals(listOf(true, false), specs.map(KeyGenParameterSpec::isStrongBoxBacked)) + assertTrue(specs.last().isUnlockedDeviceRequired) + assertTrue(FakeAndroidKeyStore.has(aliasA)) + } + + @Test + fun theUnlockedDeviceRequirementIsDroppedOnlyWhenThePlatformRejectsIt() { + FakeAndroidKeyStore.onGenerate = { if (it.isUnlockedDeviceRequired) throw ProviderException("rejected") } + + factor.ensureKey(PinKeySlot.A) + + val specs = FakeAndroidKeyStore.specs + assertEquals( + listOf(true, false, false), + specs.map(KeyGenParameterSpec::isStrongBoxBacked), + ) + assertFalse(specs.last().isStrongBoxBacked) + assertFalse(specs.last().isUnlockedDeviceRequired) + assertTrue(FakeAndroidKeyStore.has(aliasA)) + } + + @Test + fun whenNoOptionWorksTheFailureIsTransientAndNoKeyIsLeftBehind() { + FakeAndroidKeyStore.onGenerate = { throw ProviderException("keystore down") } + + val e = failure { factor.ensureKey(PinKeySlot.A) } + + assertFalse("a failed generation may succeed later", e.lost) + assertEquals(3, FakeAndroidKeyStore.specs.size) + assertFalse(FakeAndroidKeyStore.has(aliasA)) + } + + @Test + fun aKeystoreThatCannotAnswerContainsAliasIsATransientFailure() { + FakeAndroidKeyStore.aliasFailure = KeyStoreException("locked") + + val e = failure { factor.ensureKey(PinKeySlot.A) } + + assertFalse(e.lost) + assertTrue(e.cause is KeyStoreException) + } + + @Test + fun aRuntimeErrorFromTheKeystoreIsMappedNotLeakedWhenCheckingForTheKey() { + FakeAndroidKeyStore.aliasFailure = ProviderException("binder died") + + val e = failure { factor.ensureKey(PinKeySlot.A) } + + assertFalse(e.lost) + assertTrue(e.cause is ProviderException) + } + + @Test + fun anUnrecoverableKeyMeansTheKeyIsLost() { + factor.ensureKey(PinKeySlot.A) + FakeAndroidKeyStore.getKeyFailure = UnrecoverableKeyException("gone") + + assertTrue(failure { factor.mac(PinKeySlot.A, byteArrayOf(1)) }.lost) + } + + @Test + fun anotherKeystoreErrorWhileReadingTheKeyIsTransient() { + factor.ensureKey(PinKeySlot.A) + FakeAndroidKeyStore.getKeyFailure = KeyStoreException("busy") + + val e = failure { factor.mac(PinKeySlot.A, byteArrayOf(1)) } + + assertFalse(e.lost) + assertNotEquals(null, e.cause) + } + + @Test + fun aKeyTheSystemInvalidatedIsLostForGood() { + factor.ensureKey(PinKeySlot.A) + FakeAndroidKeyStore.invalidate(aliasA) + + assertTrue(failure { factor.mac(PinKeySlot.A, byteArrayOf(1)) }.lost) + } + + @Test + fun deleteSwallowsKeystoreErrorsAndNeverThrows() { + factor.ensureKey(PinKeySlot.A) + FakeAndroidKeyStore.aliasFailure = KeyStoreException("locked") + factor.delete(PinKeySlot.A) + + FakeAndroidKeyStore.aliasFailure = ProviderException("binder died") + factor.delete(PinKeySlot.A) + + assertTrue("the key could not be removed, and nothing pretended it was", FakeAndroidKeyStore.has(aliasA)) + } + + @Test + fun theOtherSlotIsTheOtherSlot() { + assertEquals(PinKeySlot.B, PinKeySlot.A.other) + assertEquals(PinKeySlot.A, PinKeySlot.B.other) + } +} diff --git a/app/src/test/java/com/pasich/encly/core/security/PinSlotsTest.kt b/app/src/test/java/com/pasich/encly/core/security/PinSlotsTest.kt new file mode 100644 index 00000000..93a13257 --- /dev/null +++ b/app/src/test/java/com/pasich/encly/core/security/PinSlotsTest.kt @@ -0,0 +1,163 @@ +package com.pasich.encly.core.security + +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Test + +/** The slot byte formats: what is accepted, what is refused, and that nothing is opened by the wrong key. */ +class PinSlotsTest { + private val dek = ByteArray(32) { it.toByte() } + private val kek = ByteArray(32) { (it + 100).toByte() } + private val otherKek = ByteArray(32) { (it + 7).toByte() } + private val salt = ByteArray(16) { (it * 3).toByte() } + + // --- PIN slot ----------------------------------------------------------------------- + + @Test + fun aSealedSlotParsesAndOpensWithItsKekOnly() { + val slot = PinSlots.sealSlot(dek, kek, salt) + + val parsed = PinSlots.parseSlot(slot)!! + + assertArrayEquals(salt, parsed.salt) + assertArrayEquals(dek, PinSlots.openSlotOrNull(parsed, kek)) + assertNull(PinSlots.openSlotOrNull(parsed, otherKek)) + } + + @Test + fun aSlotOfTheWrongLengthDoesNotParse() { + val slot = PinSlots.sealSlot(dek, kek, salt) + + assertNull(PinSlots.parseSlot(slot.copyOf(slot.size - 1))) + assertNull(PinSlots.parseSlot(slot + 0)) + assertNull(PinSlots.parseSlot(ByteArray(0))) + } + + @Test + fun aSlotOfAnotherVersionDoesNotParse() { + val slot = PinSlots.sealSlot(dek, kek, salt).also { it[0] = (PinSlots.SLOT_VERSION + 1).toByte() } + + assertNull(PinSlots.parseSlot(slot)) + } + + @Test + fun aSlotMovedToAnotherSaltIsNotOpened() { + val slot = PinSlots.sealSlot(dek, kek, salt) + // The salt is authenticated: swapping it for another one must break the tag. + slot[1] = (slot[1] + 1).toByte() + + assertNull(PinSlots.openSlotOrNull(PinSlots.parseSlot(slot)!!, kek)) + } + + @Test + fun aFlippedCiphertextBitIsNotOpened() { + val slot = PinSlots.sealSlot(dek, kek, salt) + slot[slot.size - 1] = (slot.last().toInt() xor 1).toByte() + + assertNull(PinSlots.openSlotOrNull(PinSlots.parseSlot(slot)!!, kek)) + } + + // --- wipe slot ------------------------------------------------------------------------ + + @Test + fun aWipeSlotOpensOnlyWithItsKekAndSalt() { + val slot = PinSlots.sealWipeSlot(kek, salt) + + assertTrue(PinSlots.opensWipeSlot(slot, kek, salt)) + assertFalse(PinSlots.opensWipeSlot(slot, otherKek, salt)) + assertFalse(PinSlots.opensWipeSlot(slot, kek, ByteArray(16))) + } + + @Test + fun aDecoyWipeSlotLooksLikeARealOneButNothingOpensIt() { + val real = PinSlots.sealWipeSlot(kek, salt) + val decoy = PinSlots.decoyWipeSlot() + + assertEquals(real.size, decoy.size) + assertEquals(real[0], decoy[0]) + assertFalse(PinSlots.opensWipeSlot(decoy, kek, salt)) + } + + @Test + fun aMalformedWipeSlotIsNeverOpened() { + val slot = PinSlots.sealWipeSlot(kek, salt) + + assertFalse(PinSlots.opensWipeSlot(slot.copyOf(slot.size - 1), kek, salt)) + assertFalse(PinSlots.opensWipeSlot(slot.copyOf().also { it[0] = 99 }, kek, salt)) + assertFalse(PinSlots.opensWipeSlot(ByteArray(0), kek, salt)) + } + + @Test + fun theWipeAndPinSlotsAreBoundToDifferentContexts() { + assertFalse(PinSlots.aad(salt).contentEquals(PinSlots.wipeAad(salt))) + } + + // --- duress slot ---------------------------------------------------------------------- + + @Test + fun aDecoySlotOpensWithItsKekAndAadOnly() { + val aad = DuressSlots.decoySlotAad(salt) + val sealed = DuressSlots.sealDecoy(dek, kek, aad) + + assertArrayEquals(dek, DuressSlots.openDecoy(sealed, kek, aad)) + assertNull(DuressSlots.openDecoy(sealed, otherKek, aad)) + assertNull(DuressSlots.openDecoy(sealed, kek, DuressSlots.decoySlotAad(ByteArray(16)))) + } + + @Test + fun aMalformedDecoySlotIsNeverOpened() { + val aad = DuressSlots.decoySlotAad(salt) + val sealed = DuressSlots.sealDecoy(dek, kek, aad) + + assertNull(DuressSlots.openDecoy(sealed.copyOf(sealed.size - 1), kek, aad)) + assertNull(DuressSlots.openDecoy(sealed.copyOf().also { it[0] = 99 }, kek, aad)) + } + + @Test + fun theDecoyKeyKekDependsOnTheVaultKeyAndIsNotTheKeyItself() { + val first = DuressSlots.decoyKeyKek(dek) + + assertEquals(32, first.size) + assertFalse(first.contentEquals(dek)) + assertArrayEquals(first, DuressSlots.decoyKeyKek(dek.copyOf())) + assertFalse(first.contentEquals(DuressSlots.decoyKeyKek(otherKek))) + } + + // --- HKDF ----------------------------------------------------------------------------- + + @Test + fun hkdfMatchesRfc5869TestCase1ForTheFirst32Bytes() { + val ikm = ByteArray(22) { 0x0b } + val hkdfSalt = (0..12).map { it.toByte() }.toByteArray() + val info = (0xf0..0xf9).map { it.toByte() }.toByteArray() + + val okm = Hkdf.sha256(ikm, hkdfSalt, info, 32) + + assertEquals( + "3cb25f25faacd57a90434f64d0362f2a2d2d0a90cf1a5a4c5db02d56ecc4c5bf", + okm.joinToString("") { "%02x".format(it) }, + ) + } + + @Test + fun hkdfRefusesALengthOutsideOneHashBlockAndAnEmptySalt() { + val ikm = ByteArray(32) + + assertThrows(IllegalArgumentException::class.java) { Hkdf.sha256(ikm, salt, ByteArray(0), 0) } + assertThrows(IllegalArgumentException::class.java) { Hkdf.sha256(ikm, salt, ByteArray(0), 33) } + assertThrows(IllegalArgumentException::class.java) { Hkdf.sha256(ikm, ByteArray(0), ByteArray(0), 16) } + } + + @Test + fun differentInfoGivesIndependentKeys() { + val a = Hkdf.sha256(dek, salt, "a".toByteArray(), 32) + val b = Hkdf.sha256(dek, salt, "b".toByteArray(), 32) + + assertFalse(a.contentEquals(b)) + assertEquals(16, Hkdf.sha256(dek, salt, "a".toByteArray(), 16).size) + } +} diff --git a/app/src/test/java/com/pasich/encly/core/security/SQLCipherUtilsTest.kt b/app/src/test/java/com/pasich/encly/core/security/SQLCipherUtilsTest.kt new file mode 100644 index 00000000..12249c8e --- /dev/null +++ b/app/src/test/java/com/pasich/encly/core/security/SQLCipherUtilsTest.kt @@ -0,0 +1,55 @@ +package com.pasich.encly.core.security + +import android.app.Application +import androidx.test.core.app.ApplicationProvider +import com.pasich.encly.core.security.cipher.SQLCipherUtils +import com.pasich.encly.core.security.cipher.SQLCipherUtils.State +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.io.File + +/** The database file is classified by its header alone: missing, plaintext SQLite, or anything else. */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class SQLCipherUtilsTest { + private val context: Application get() = ApplicationProvider.getApplicationContext() + + private fun dbFile(name: String): File = context.getDatabasePath(name).also { it.parentFile?.mkdirs() } + + @Test + fun aMissingFileDoesNotExist() { + assertEquals(State.DOES_NOT_EXIST, SQLCipherUtils.getDatabaseState(context, "no-such.db")) + } + + @Test + fun aFileWithTheRealSqliteHeaderIsPlaintext() { + dbFile("plain.db").writeBytes("SQLite format 3\u0000".toByteArray(Charsets.US_ASCII) + ByteArray(100)) + + assertEquals(State.UNENCRYPTED, SQLCipherUtils.getDatabaseState(context, "plain.db")) + } + + @Test + fun aFileWithAnyOtherHeaderIsTreatedAsEncrypted() { + dbFile("cipher.db").writeBytes(ByteArray(4096) { (it * 31).toByte() }) + + assertEquals(State.ENCRYPTED, SQLCipherUtils.getDatabaseState(context, "cipher.db")) + } + + @Test + fun aFileShorterThanTheMagicIsNotPlaintext() { + dbFile("short.db").writeBytes("SQLite".toByteArray()) + + assertEquals(State.ENCRYPTED, SQLCipherUtils.getDatabaseState(context, "short.db")) + } + + @Test + fun anUnreadablePathIsTreatedAsEncryptedNotAsPlaintext() { + // A directory exists but cannot be read as a file: failing closed means "encrypted". + dbFile("dir.db").also { it.mkdirs() } + + assertEquals(State.ENCRYPTED, SQLCipherUtils.getDatabaseState(context, "dir.db")) + } +} diff --git a/app/src/test/java/com/pasich/encly/core/security/SecurityManagerLifecycleTest.kt b/app/src/test/java/com/pasich/encly/core/security/SecurityManagerLifecycleTest.kt new file mode 100644 index 00000000..b2f899f6 --- /dev/null +++ b/app/src/test/java/com/pasich/encly/core/security/SecurityManagerLifecycleTest.kt @@ -0,0 +1,375 @@ +package com.pasich.encly.core.security + +import com.pasich.encly.data.database.SecureDatabaseManager +import com.pasich.encly.testutil.FakeLockoutClock +import com.pasich.encly.testutil.FakePinFactor +import com.pasich.encly.testutil.InMemorySharedPreferences +import com.pasich.encly.testutil.MockActivity +import com.pasich.encly.testutil.anyByteArray +import com.pasich.encly.testutil.tempVaultFile +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.mockito.ArgumentMatchers.anyBoolean +import org.mockito.Mockito +import org.mockito.Mockito.clearInvocations +import org.mockito.Mockito.mock +import org.mockito.Mockito.never +import org.mockito.Mockito.verify +import org.mockito.Mockito.`when` +import java.io.File + +/** + * SecurityManager over real slot stores and a mocked database: where first-run setup, startup + * routing and session locking send the user, and what a locked or half-set-up vault refuses. + */ +class SecurityManagerLifecycleTest { + private lateinit var file: File + private lateinit var store: VaultStore + private lateinit var prefs: InMemorySharedPreferences + private lateinit var seed: SeedPhraseManager + private lateinit var auth: AuthenticationManager + private lateinit var database: SecureDatabaseManager + private lateinit var biometric: BiometricManager + private var slotPresent = false + private var enrollResult = true + private var enrollCalls = 0 + private var disableCalls = 0 + private var prompt: BiometricManager.BiometricCallback? = null + private lateinit var manager: SecurityManager + + @Before + fun setUp() { + file = tempVaultFile() + store = VaultStore(file) + prefs = InMemorySharedPreferences() + seed = SeedPhraseManager(store) + auth = AuthenticationManager(store, FakePinFactor(), FakeLockoutClock()) + database = mock(SecureDatabaseManager::class.java) + biometric = mock(BiometricManager::class.java) { call -> + when (call.method.name) { + "hasSlot" -> slotPresent + + "disable" -> { + disableCalls++ + null + } + + "enroll" -> { + enrollCalls++ + @Suppress("UNCHECKED_CAST") + (call.getArgument(2) as (Boolean) -> Unit)(enrollResult) + null + } + + "authenticate" -> { + prompt = call.getArgument(2) + null + } + + else -> Mockito.RETURNS_DEFAULTS.answer(call) + } + } + manager = SecurityManager(prefs, store, seed, database, auth, biometric) {} + `when`(database.unlockDatabase(anyByteArray(), anyBoolean())).thenReturn(true) + } + + /** First-run setup up to and including the committed onboarding, with a recovery phrase. */ + private fun committedVault(withRecoveryPhrase: Boolean = true): CharArray { + val phrase = seed.generateMnemonic().chars + assertTrue(manager.initializeNewVault(if (withRecoveryPhrase) phrase.copyOf() else null)) + assertTrue(manager.configurePin(PIN.toCharArray())) + assertTrue(manager.finishInitialSetup()) + manager.lock() + `when`(database.hasEncryptedDatabase()).thenReturn(true) + return phrase + } + + // --- startup routing ------------------------------------------------------------------ + + @Test + fun aDamagedStateFileRoutesToTheDamagedVaultScreen() { + file.writeBytes(ByteArray(50) { 1 }) + + assertEquals(InitialStatus.LOSS_CRYPTO, manager.resolveInitialStatus()) + assertEquals(InitialStatus.LOSS_CRYPTO, manager.securityStatus) + } + + @Test + fun anEncryptedDatabaseWithoutVaultMetadataIsALegacyVault() { + `when`(database.hasEncryptedDatabase()).thenReturn(true) + + assertEquals(InitialStatus.LEGACY_VAULT, manager.resolveInitialStatus()) + } + + @Test + fun aCommittedVaultWithoutItsDatabaseRoutesToDatabaseLoss() { + committedVault() + `when`(database.hasEncryptedDatabase()).thenReturn(false) + + assertEquals(InitialStatus.LOSS_DATABASE, manager.resolveInitialStatus()) + } + + @Test + fun aCommittedVaultAsksForTheLockScreen() { + committedVault() + + assertEquals(InitialStatus.AUTH, manager.resolveInitialStatus()) + assertTrue(manager.isLockable()) + } + + @Test + fun aVaultWithoutAPinSlotAndWithoutARecoveryPhraseHasNoWayIn() { + committedVault(withRecoveryPhrase = false) + auth.wipe() + + assertEquals(InitialStatus.LOSS_CRYPTO, manager.resolveInitialStatus()) + } + + @Test + fun aVaultWithoutAPinSlotStillAsksForTheRecoveryPhrase() { + committedVault() + auth.wipe() + + assertEquals(InitialStatus.AUTH, manager.resolveInitialStatus()) + } + + @Test + fun anOpenVaultStaysOpenWhenTheActivityAsksAgain() { + committedVault() + assertEquals(VaultUnlockResult.SUCCESS, manager.unlockWithPin(PIN.toCharArray())) + `when`(database.isDatabaseUnlocked()).thenReturn(true) + + assertEquals(InitialStatus.MAIN, manager.resolveInitialStatus()) + } + + // --- first-run setup -------------------------------------------------------------------- + + @Test + fun aFreshVaultIsNotLockableBeforeOnboardingIsCommitted() { + assertTrue(manager.initializeNewVault(null)) + assertTrue(manager.configurePin(PIN.toCharArray())) + + assertFalse(manager.isLockable()) + assertTrue(manager.isOnboardingShow()) + } + + @Test + fun onboardingCanNotBeCommittedWithoutAPinSlot() { + assertTrue(manager.initializeNewVault(null)) + + assertFalse(manager.setOnboardingShown()) + assertFalse(manager.finishInitialSetup()) + assertTrue(manager.isOnboardingShow()) + } + + @Test + fun theInitialVaultIsNotOpenedWithoutAPinSlot() { + assertTrue(manager.initializeNewVault(null)) + + assertFalse(manager.openInitialVault()) + verify(database, never()).unlockDatabase(anyByteArray(), anyBoolean()) + } + + @Test + fun aCommittedVaultCanNotBeRecreatedOverItsData() { + committedVault() + + assertFalse(manager.initializeNewVault(null)) + assertTrue(manager.hasRecoverySeed()) + } + + @Test + fun anInvalidRecoveryPhraseDoesNotStartAVault() { + assertFalse(manager.initializeNewVault("not a real phrase".toCharArray())) + assertFalse(manager.isValidRecoveryPhrase("not a real phrase".toCharArray())) + } + + @Test + fun committingSetupDropsTheBootstrapKeySoNothingCanBeEnrolledFromIt() { + committedVault() + var enrolled: Boolean? = null + + manager.enrollBiometric(MockActivity().activity) { enrolled = it } + + assertEquals(false, enrolled) + assertEquals(0, enrollCalls) + } + + // --- unlocking and locking ------------------------------------------------------------ + + @Test + fun aDatabaseThatDoesNotOpenFailsThePinUnlockWithoutOpeningASession() { + committedVault() + `when`(database.unlockDatabase(anyByteArray(), anyBoolean())).thenReturn(false) + + assertEquals(VaultUnlockResult.DB_ERROR, manager.unlockWithPin(PIN.toCharArray())) + assertNull(manager.copyBackupRootKey()) + assertFalse(manager.canResetPinWithoutCurrent()) + } + + @Test + fun aWrongPinIsAnInvalidCredential() { + committedVault() + + assertEquals(VaultUnlockResult.INVALID_CREDENTIAL, manager.unlockWithPin(WRONG_PIN.toCharArray())) + } + + @Test + fun aWrongRecoveryPhraseIsAnInvalidCredential() { + committedVault() + val other = seed.generateMnemonic().chars + + assertEquals(VaultUnlockResult.INVALID_CREDENTIAL, manager.unlockWithSeed(other)) + assertFalse(manager.verifySeed(other)) + } + + @Test + fun theRecoveryPhraseOpensTheVaultAndLetsThePinBeResetOnce() { + val phrase = committedVault() + assertTrue(manager.verifySeed(phrase.copyOf())) + + assertEquals(VaultUnlockResult.SUCCESS, manager.unlockWithSeed(phrase.copyOf())) + assertTrue(manager.canResetPinWithoutCurrent()) + + assertEquals(PinChange.SET, manager.changePin(NEW_PIN.toCharArray())) + assertFalse("a new PIN ends the recovery privilege", manager.canResetPinWithoutCurrent()) + } + + @Test + fun aPinUnlockDoesNotGrantTheForgottenPinPrivilege() { + committedVault() + + assertEquals(VaultUnlockResult.SUCCESS, manager.unlockWithPin(PIN.toCharArray())) + + assertFalse(manager.canResetPinWithoutCurrent()) + } + + @Test + fun lockingEndsTheSessionAndWithdrawsTheKeysItHeld() { + val phrase = committedVault() + assertEquals(VaultUnlockResult.SUCCESS, manager.unlockWithSeed(phrase.copyOf())) + assertTrue(manager.hasBackupKey()) + assertNotNull("the open session can read the backup root", manager.copyBackupRootKey()) + + clearInvocations(database) + + manager.lock() + + assertEquals(InitialStatus.AUTH, manager.securityStatus) + assertNull(manager.copyBackupRootKey()) + assertFalse(manager.addRecoverySeed(seed.generateMnemonic().chars)) + assertFalse(manager.replaceRecoverySeed(seed.generateMnemonic().chars)) + assertFalse(manager.createBackupKey(phrase.copyOf())) + assertFalse(manager.canResetPinWithoutCurrent()) + verify(database).reset() + } + + @Test + fun aKeyOfTheWrongSizeNeverOpensTheDatabase() { + assertFalse(manager.unlockWithRawKey(ByteArray(31))) + assertFalse(manager.unlockWithRawKey(ByteArray(33), allowCreate = true)) + + verify(database, never()).unlockDatabase(anyByteArray(), anyBoolean()) + } + + @Test + fun theLockoutSurfacesThroughTheManager() { + committedVault() + repeat(LOCKOUT_AFTER) { manager.unlockWithPin(WRONG_PIN.toCharArray()) } + + assertTrue(manager.pinLockoutRemainingMillis() > 0) + assertEquals(VaultUnlockResult.LOCKED_OUT, manager.unlockWithPin(PIN.toCharArray())) + } + + @Test + fun verifyingThePinCountsAndAcceptsOnlyTheRealOne() { + committedVault() + assertEquals(VaultUnlockResult.SUCCESS, manager.unlockWithPin(PIN.toCharArray())) + + assertTrue(manager.verifyPin(PIN.toCharArray())) + assertFalse(manager.verifyPin(WRONG_PIN.toCharArray())) + } + + // --- biometric plumbing ----------------------------------------------------------------- + + @Test + fun enrollingRecordsTheResultOfTheBiometricSlot() { + committedVault() + manager.unlockWithPin(PIN.toCharArray()) + slotPresent = true + var result: Boolean? = null + + manager.enrollBiometric(MockActivity().activity) { result = it } + + assertEquals(true, result) + assertTrue(manager.isBiometricEnabled()) + assertTrue(manager.getSettingsAuth().isBiometricEnabled) + } + + @Test + fun aFailedEnrollmentLeavesBiometricsOff() { + committedVault() + manager.unlockWithPin(PIN.toCharArray()) + slotPresent = true + enrollResult = false + var result: Boolean? = null + + manager.enrollBiometric(MockActivity().activity) { result = it } + + assertEquals(false, result) + assertFalse(manager.isBiometricEnabled()) + } + + @Test + fun disablingBiometricsClearsTheSlotAndTheFlag() { + committedVault() + manager.unlockWithPin(PIN.toCharArray()) + auth.markBiometricEnabled(true) + slotPresent = true + assertTrue(manager.isBiometricEnabled()) + disableCalls = 0 + + manager.disableBiometric() + + assertEquals(1, disableCalls) + assertFalse(manager.isBiometricEnabled()) + } + + @Test + fun theFlagAloneIsNotEnoughWithoutTheKeystoreSlot() { + committedVault() + auth.markBiometricEnabled(true) + slotPresent = false + + assertFalse(manager.isBiometricEnabled()) + } + + @Test + fun theFingerprintConfirmationMapsEveryOutcomeToYesOrNo() { + committedVault() + manager.unlockWithPin(PIN.toCharArray()) + val results = mutableListOf() + + manager.confirmBiometric(MockActivity().activity) { results += it } + val callback = checkNotNull(prompt) + callback.onFailed() + assertEquals("a single bad scan is not an answer", emptyList(), results) + callback.onSuccess() + callback.onError(7, "locked") + callback.onCancelled() + + assertEquals(listOf(true, false, false), results) + } + + private companion object { + const val PIN = "135790" + const val NEW_PIN = "246813" + const val WRONG_PIN = "000111" + const val LOCKOUT_AFTER = 5 + } +} diff --git a/app/src/test/java/com/pasich/encly/core/security/SecurityManagerTest.kt b/app/src/test/java/com/pasich/encly/core/security/SecurityManagerTest.kt index c1029a1b..83dd3637 100644 --- a/app/src/test/java/com/pasich/encly/core/security/SecurityManagerTest.kt +++ b/app/src/test/java/com/pasich/encly/core/security/SecurityManagerTest.kt @@ -223,7 +223,7 @@ class SecurityManagerTest { @Test fun lockClosesTheDatabaseAndDropsTheSessionKey() { `when`(database.unlockDatabase(anyByteArray(), anyBoolean())).thenReturn(true) - `when`(auth.configurePin(anyCharArray(), anyByteArray())).thenReturn(true) + `when`(auth.changePin(anyCharArray(), anyByteArray())).thenReturn(PinChange.SET) assertTrue(manager.unlockWithRawKey(ByteArray(32) { 7 })) assertTrue(manager.configurePin("123456".toCharArray())) // the live session DEK is available @@ -242,7 +242,7 @@ class SecurityManagerTest { val phrase = "words".toCharArray() `when`(seed.unlockWithSeed(phrase)).thenReturn(ByteArray(32) { 5 }) `when`(database.unlockDatabase(anyByteArray(), anyBoolean())).thenReturn(true) - `when`(auth.configurePin(anyCharArray(), anyByteArray())).thenReturn(true) + `when`(auth.changePin(anyCharArray(), anyByteArray())).thenReturn(PinChange.SET) assertEquals(VaultUnlockResult.SUCCESS, manager.unlockWithSeed(phrase)) assertTrue(manager.canResetPinWithoutCurrent()) @@ -300,7 +300,7 @@ class SecurityManagerTest { val onDamagedFile = SecurityManager(prefs, VaultStore(file), seed, database, auth, biometric) prefs.edit().putBoolean(ONBOARDING_KEY, true).commit() - onDamagedFile.wipeAndReset() + assertTrue(onDamagedFile.wipeAndReset()) assertFalse(file.exists()) assertFalse(prefs.getBoolean(ONBOARDING_KEY, false)) diff --git a/app/src/test/java/com/pasich/encly/core/security/SecurityPlatformTest.kt b/app/src/test/java/com/pasich/encly/core/security/SecurityPlatformTest.kt new file mode 100644 index 00000000..87cd87e4 --- /dev/null +++ b/app/src/test/java/com/pasich/encly/core/security/SecurityPlatformTest.kt @@ -0,0 +1,115 @@ +package com.pasich.encly.core.security + +import android.app.Application +import android.content.Context +import android.content.Intent +import android.provider.Settings +import androidx.test.core.app.ApplicationProvider +import com.pasich.encly.testutil.InMemorySharedPreferences +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config + +/** The thin Android adapters under the lock: clock, screen-off watcher, keyboard privacy flag. */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class SecurityPlatformTest { + private val context: Application get() = ApplicationProvider.getApplicationContext() + + @Test + fun theSystemClockReportsTheBootCountSetting() { + Settings.Global.putInt(context.contentResolver, Settings.Global.BOOT_COUNT, 7) + + assertEquals(7, SystemLockoutClock(context).bootCount()) + } + + @Test + fun anUnknownBootCountIsMinusOne() { + assertEquals(-1, SystemLockoutClock(context).bootCount()) + } + + @Test + fun aBrokenSettingsProviderReadsAsAnUnknownBootCount() { + val broken = object : android.content.ContextWrapper(context) { + override fun getContentResolver() = throw SecurityException("no settings") + } + + assertEquals(-1, SystemLockoutClock(broken).bootCount()) + } + + @Test + fun theSystemClockNeverGoesBackwards() { + val clock = SystemLockoutClock(context) + val first = clock.elapsedRealtime() + + assertTrue(clock.elapsedRealtime() >= first) + } + + @Test + fun theJvmClockHasNoBootCountAndAdvances() { + assertEquals(-1, JvmMonotonicClock.bootCount()) + val first = JvmMonotonicClock.elapsedRealtime() + Thread.sleep(5) + assertTrue(JvmMonotonicClock.elapsedRealtime() > first) + } + + @Test + fun theNoneWatcherReportsAnUnlockedDeviceAndIgnoresRegistrations() { + var calls = 0 + val stop = DeviceLockWatcher.None.watchScreenOff { calls++ } + stop() + + assertFalse(DeviceLockWatcher.None.isDeviceLocked()) + assertEquals(0, calls) + } + + @Test + fun theKeyguardStateDecidesWhetherTheDeviceIsLocked() { + val keyguard = context.getSystemService(Context.KEYGUARD_SERVICE) as android.app.KeyguardManager + val watcher = SystemDeviceLockWatcher(context) + + shadowOf(keyguard).setIsDeviceLocked(true) + assertTrue(watcher.isDeviceLocked()) + shadowOf(keyguard).setIsDeviceLocked(false) + assertFalse(watcher.isDeviceLocked()) + } + + @Test + fun screenOffFiresTheCallbackUntilTheWatchIsStopped() { + var offs = 0 + val stop = SystemDeviceLockWatcher(context).watchScreenOff { offs++ } + + context.sendBroadcast(Intent(Intent.ACTION_SCREEN_OFF)) + shadowOf(android.os.Looper.getMainLooper()).idle() + assertEquals(1, offs) + + context.sendBroadcast(Intent(Intent.ACTION_SCREEN_ON)) + shadowOf(android.os.Looper.getMainLooper()).idle() + assertEquals("another action is not a screen-off", 1, offs) + + stop() + context.sendBroadcast(Intent(Intent.ACTION_SCREEN_OFF)) + shadowOf(android.os.Looper.getMainLooper()).idle() + assertEquals(1, offs) + stop() // stopping twice is harmless + } + + @Test + fun strictKeyboardPrivacyIsOffByDefaultAndPersistsWhenSet() { + val prefs = InMemorySharedPreferences() + val privacy = KeyboardPrivacy(prefs) + assertFalse(privacy.strict.value) + + privacy.setStrict(true) + + assertTrue(privacy.strict.value) + assertTrue(KeyboardPrivacy(prefs).strict.value) + privacy.setStrict(false) + assertFalse(KeyboardPrivacy(prefs).strict.value) + } +} diff --git a/app/src/test/java/com/pasich/encly/core/security/SessionLockManagerTest.kt b/app/src/test/java/com/pasich/encly/core/security/SessionLockManagerTest.kt index ec6291ec..e3960a99 100644 --- a/app/src/test/java/com/pasich/encly/core/security/SessionLockManagerTest.kt +++ b/app/src/test/java/com/pasich/encly/core/security/SessionLockManagerTest.kt @@ -371,6 +371,29 @@ class SessionLockManagerTest { assertTrue(manager.locked.value) } + @Test + fun fifteenMinuteDelayLocksAtTheBoundary() { + withDelay(AutoLockDelay.MINUTES_15.millis) + + manager.onStop(owner) + now += AutoLockDelay.MINUTES_15.millis + manager.onStart(owner) + + verify(security).lock() + assertTrue(manager.locked.value) + } + + @Test + fun thirtyMinuteDelayStillEndsOnScreenOff() { + withDelay(AutoLockDelay.MINUTES_30.millis) + + manager.onStop(owner) + watcher.screenOff() + + verify(security).lock() + assertTrue(manager.locked.value) + } + @Test fun theScreenTurningOffIgnoresTheAutoLockDelay() { withDelay(120_000) diff --git a/app/src/test/java/com/pasich/encly/core/security/SessionLockTimerTest.kt b/app/src/test/java/com/pasich/encly/core/security/SessionLockTimerTest.kt new file mode 100644 index 00000000..d4fa49d9 --- /dev/null +++ b/app/src/test/java/com/pasich/encly/core/security/SessionLockTimerTest.kt @@ -0,0 +1,66 @@ +package com.pasich.encly.core.security + +import android.os.Looper +import androidx.lifecycle.LifecycleOwner +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mockito.mock +import org.mockito.Mockito.never +import org.mockito.Mockito.verify +import org.mockito.Mockito.`when` +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config +import java.util.concurrent.TimeUnit + +/** The auto-lock delay on the real main-thread timer: it fires once, and coming back cancels it. */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class SessionLockTimerTest { + private val owner = mock(LifecycleOwner::class.java) + private val security = mock(SecurityManager::class.java).also { + `when`(it.isLockable()).thenReturn(true) + `when`(it.isDatabaseUnlocked()).thenReturn(true) + } + private val manager = SessionLockManager( + security, + autoLock = object : AutoLockPolicy { + override val delayMillis = DELAY_MS + }, + ) + + private fun pass(ms: Long) = shadowOf(Looper.getMainLooper()).idleFor(ms, TimeUnit.MILLISECONDS) + + @Test + fun theVaultStaysOpenForTheDelayAndLocksWhenItRunsOut() { + manager.onStart(owner) + + manager.onStop(owner) + pass(DELAY_MS - 1) + verify(security, never()).lock() + assertFalse(manager.locked.value) + + pass(2) + verify(security).lock() + assertTrue(manager.locked.value) + } + + @Test + fun comingBackInTimeCancelsTheTimer() { + manager.onStart(owner) + manager.onStop(owner) + pass(DELAY_MS / 2) + + manager.onStart(owner) + pass(DELAY_MS * 2) + + verify(security, never()).lock() + assertFalse(manager.locked.value) + } + + private companion object { + const val DELAY_MS = 15_000L + } +} diff --git a/app/src/test/java/com/pasich/encly/core/security/VaultStoreFormatTest.kt b/app/src/test/java/com/pasich/encly/core/security/VaultStoreFormatTest.kt new file mode 100644 index 00000000..a9b62114 --- /dev/null +++ b/app/src/test/java/com/pasich/encly/core/security/VaultStoreFormatTest.kt @@ -0,0 +1,195 @@ +package com.pasich.encly.core.security + +import com.pasich.encly.testutil.tempVaultFile +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.ByteArrayOutputStream +import java.io.DataOutputStream +import java.security.MessageDigest + +/** + * The slot file's format as seen from outside: files that pass the checksum but are not a valid + * store (wrong magic, absurd sizes, trailing bytes) are damage, and a value read back with the + * wrong type falls to the default instead of being misread. + */ +class VaultStoreFormatTest { + private val file = tempVaultFile() + + /** A file with a valid checksum around [body]. */ + private fun write(body: ByteArray) { + file.writeBytes(body + MessageDigest.getInstance("SHA-256").digest(body)) + } + + private fun body( + magic: ByteArray = "ENCLYVS3".toByteArray(Charsets.US_ASCII), + count: Int, + entries: DataOutputStream.() -> Unit = {}, + trailing: ByteArray = ByteArray(0), + ): ByteArray { + val out = ByteArrayOutputStream() + DataOutputStream(out).apply { + write(magic) + writeInt(count) + entries() + } + return out.toByteArray() + trailing + } + + @Test + fun aWellFormedFileFromOutsideIsReadBack() { + write( + body( + count = 1, + entries = { + writeUTF("k") + writeInt(2) + write(byteArrayOf(7, 8)) + }, + ), + ) + val store = VaultStore(file) + + assertFalse(store.isCorrupt()) + assertArrayEquals(byteArrayOf(7, 8), store.getBytes("k")) + } + + @Test + fun aFileWithAnotherMagicIsDamageEvenWithAValidChecksum() { + write(body(magic = "ENCLYVS2".toByteArray(Charsets.US_ASCII), count = 0)) + + assertTrue(VaultStore(file).isCorrupt()) + } + + @Test + fun anEntryCountBeyondTheLimitOrBelowZeroIsDamage() { + write(body(count = 257)) + assertTrue(VaultStore(file).isCorrupt()) + + write(body(count = -1)) + assertTrue(VaultStore(file).isCorrupt()) + } + + @Test + fun aValueLengthBeyondTheLimitIsDamage() { + write( + body( + count = 1, + entries = { + writeUTF("k") + writeInt(64 * 1024 + 1) + }, + ), + ) + + assertTrue(VaultStore(file).isCorrupt()) + } + + @Test + fun aNegativeValueLengthIsDamage() { + write( + body( + count = 1, + entries = { + writeUTF("k") + writeInt(-5) + }, + ), + ) + + assertTrue(VaultStore(file).isCorrupt()) + } + + @Test + fun anEntryCutShortByTheEndOfTheBodyIsDamage() { + write( + body( + count = 2, + entries = { + writeUTF("k") + writeInt(1) + write(byteArrayOf(1)) + }, + ), + ) + + assertTrue(VaultStore(file).isCorrupt()) + } + + @Test + fun bytesAfterTheLastEntryAreDamage() { + write(body(count = 0, trailing = byteArrayOf(1, 2, 3))) + + assertTrue(VaultStore(file).isCorrupt()) + } + + @Test + fun aChecksumThatDoesNotMatchIsDamage() { + write(body(count = 0)) + file.writeBytes(file.readBytes().also { it[it.size - 1] = (it.last().toInt() xor 1).toByte() }) + + assertTrue(VaultStore(file).isCorrupt()) + } + + @Test + fun aDamagedStoreRefusesEveryWriteUntilItIsCleared() { + file.writeBytes(ByteArray(80) { 9 }) + val store = VaultStore(file) + + assertFalse(store.edit { putInt("a", 1) }) + assertFalse(store.contains("a")) + + assertTrue(store.clear()) + assertFalse(store.isCorrupt()) + assertTrue(store.edit { putInt("a", 1) }) + assertEquals(1, VaultStore(file).getInt("a", 0)) + } + + @Test + fun aValueReadWithTheWrongTypeFallsToTheDefault() { + val store = VaultStore(file) + store.edit { + putBytes("three", byteArrayOf(1, 2, 3)) + putInt("int", 5) + putLong("long", 6L) + putBoolean("flag", true) + } + + assertEquals(-1, store.getInt("three", -1)) + assertEquals(-2L, store.getLong("three", -2L)) + assertFalse(store.getBoolean("three", false)) + assertEquals(-1L, store.getLong("int", -1L)) + assertEquals(-1, store.getInt("long", -1)) + assertEquals(-1, store.getInt("flag", -1)) + assertEquals(9, store.getInt("missing", 9)) + } + + @Test + fun aBooleanThatIsNotOneReadsAsFalse() { + val store = VaultStore(file) + store.edit { putBytes("b", byteArrayOf(2)) } + + assertFalse(store.getBoolean("b", true)) + } + + @Test + fun aFileThatIsGoneAfterLoadingIsStillCleared() { + val store = VaultStore(file) + store.edit { putInt("a", 1) } + file.delete() + + assertTrue(store.clear()) + assertFalse(store.contains("a")) + } + + @Test + fun aStoreWhoseDirectoryIsAFileCanNotBeWrittenAndSaysSo() { + val blocker = tempVaultFile().also { it.writeBytes(byteArrayOf(1)) } + val store = VaultStore(java.io.File(blocker, "vault_state.bin")) + + assertFalse(store.edit { putInt("a", 1) }) + assertFalse(store.contains("a")) + } +} diff --git a/app/src/test/java/com/pasich/encly/data/backup/BackupManagerTest.kt b/app/src/test/java/com/pasich/encly/data/backup/BackupManagerTest.kt index 8a1c4edb..24f4fad1 100644 --- a/app/src/test/java/com/pasich/encly/data/backup/BackupManagerTest.kt +++ b/app/src/test/java/com/pasich/encly/data/backup/BackupManagerTest.kt @@ -3,6 +3,7 @@ package com.pasich.encly.data.backup import com.pasich.encly.core.backup.BackupError import com.pasich.encly.core.backup.BackupException import com.pasich.encly.core.backup.BackupFormat +import com.pasich.encly.core.backup.BackupPayload import com.pasich.encly.core.security.SecurityManager import com.pasich.encly.data.model.Note import com.pasich.encly.testutil.InMemorySharedPreferences @@ -66,6 +67,47 @@ class BackupManagerTest { assertEquals(BackupFormat.MAX_FILE_BYTES, manager.maxFileBytes) } + @Test + fun exportRefusesAStoreWhoseSessionClosedAfterSnapshot() = runTest { + val security = mock(SecurityManager::class.java) + `when`(security.copyBackupRootKey()).thenAnswer { ByteArray(ROOT_LENGTH) { 5 } } + val memory = InMemoryVaultDataStore() + var active = true + val bound = object : VaultDataStore by memory { + override suspend fun snapshot(): VaultSnapshot = memory.snapshot().also { active = false } + + override fun assertCurrentSession() { + if (!active) error("session closed") + } + } + val store = object : VaultDataStore by memory { + override fun bindToCurrentSession(): VaultDataStore = bound + } + + try { + BackupManager(security, store, InMemorySharedPreferences()).createBackup() + fail("expected a closed-session failure") + } catch (e: BackupException) { + assertEquals(BackupError.IO, e.error) + } + } + + @Test + fun theDecoyVaultRefusesEveryBackupStepAsAStorageError() = runTest { + val security = mock(SecurityManager::class.java) + `when`(security.isDecoySession()).thenReturn(true) + val decoy = BackupManager(security, InMemoryVaultDataStore(), InMemorySharedPreferences()) + val empty = BackupPayload(exportedAt = 0, tags = emptyList(), notes = emptyList(), tasks = emptyList()) + + val errors = listOf( + runCatching { decoy.decrypt(ByteArray(1), "words".toCharArray()) }, + runCatching { decoy.import(empty, ImportMode.MERGE) }, + runCatching { decoy.createBackup() }, + ).map { (it.exceptionOrNull() as BackupException).error } + + assertEquals(List(3) { BackupError.IO }, errors) + } + private companion object { const val ROOT_LENGTH = 32 } diff --git a/app/src/test/java/com/pasich/encly/data/database/SecureDatabaseManagerTest.kt b/app/src/test/java/com/pasich/encly/data/database/SecureDatabaseManagerTest.kt new file mode 100644 index 00000000..d2ba21f3 --- /dev/null +++ b/app/src/test/java/com/pasich/encly/data/database/SecureDatabaseManagerTest.kt @@ -0,0 +1,143 @@ +package com.pasich.encly.data.database + +import android.app.Application +import androidx.test.core.app.ApplicationProvider +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.io.File + +/** + * The parts of the database owner that do not need the native SQLCipher library: the raw-key + * syntax, the refusals before any file is touched, and which files a wipe removes. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class SecureDatabaseManagerTest { + private val context: Application get() = ApplicationProvider.getApplicationContext() + private lateinit var manager: SecureDatabaseManager + + @Before + fun setUp() { + manager = SecureDatabaseManager(context) + listOf(VaultFile.PRIMARY, VaultFile.DECOY).forEach { vault -> + suffixes.forEach { File(context.getDatabasePath(vault.fileName).path + it).delete() } + } + } + + private val suffixes = listOf("", "-wal", "-shm", "-journal") + + private fun files(vault: VaultFile) = suffixes.map { File(context.getDatabasePath(vault.fileName).path + it) } + + private fun createFiles(vault: VaultFile) = files(vault).forEach { + it.parentFile?.mkdirs() + it.writeBytes(ByteArray(64) { 7 }) + } + + @Test + fun theRawKeyIsHexInsideSqlcipherRawKeySyntax() { + val dek = ByteArray(32) { (it * 8 + 1).toByte() } + + val passphrase = SecureDatabaseManager.rawKeyPassphrase(dek) + + val expected = "x'" + dek.joinToString("") { "%02x".format(it) } + "'" + assertEquals(67, passphrase.size) + assertArrayEquals(expected.toByteArray(Charsets.US_ASCII), passphrase) + } + + @Test + fun theRawKeyUsesLowercaseHexForHighBytes() { + val passphrase = SecureDatabaseManager.rawKeyPassphrase(ByteArray(32) { 0xAB.toByte() }) + + assertEquals("x'" + "ab".repeat(32) + "'", String(passphrase, Charsets.US_ASCII)) + } + + @Test + fun aKeyOfTheWrongSizeIsRefusedWithoutTouchingAnyFile() { + assertFalse(manager.unlockDatabase(ByteArray(31), allowCreate = true)) + assertFalse(manager.unlockDatabase(ByteArray(33), allowCreate = true, file = VaultFile.DECOY)) + + assertFalse(manager.isDatabaseUnlocked()) + assertFalse(context.getDatabasePath(VaultFile.PRIMARY.fileName).exists()) + } + + @Test + fun aLockedManagerHasNoLeaseAndGrantsNoDatabase() { + assertNull(manager.captureLease()) + assertFalse(manager.isDatabaseUnlocked()) + assertThrows(IllegalStateException::class.java) { manager.getDatabase(DatabaseLease()) } + } + + @Test + fun resettingALockedManagerIsHarmless() { + manager.reset() + manager.reset() + + assertFalse(manager.isDatabaseUnlocked()) + assertNull(manager.captureLease()) + } + + @Test + fun theTwoVaultsHaveTheirOwnFiles() { + assertEquals("database.db", VaultFile.PRIMARY.fileName) + assertEquals("vault_b.db", VaultFile.DECOY.fileName) + } + + @Test + fun noFileMeansNoEncryptedDatabase() { + assertFalse(manager.hasEncryptedDatabase()) + assertFalse(manager.hasEncryptedDatabase(VaultFile.DECOY)) + } + + @Test + fun aFileWithoutTheSqliteHeaderCountsAsTheEncryptedDatabase() { + context.getDatabasePath(VaultFile.DECOY.fileName).apply { + parentFile?.mkdirs() + writeBytes(ByteArray(4096) { (it * 13).toByte() }) + } + + assertTrue(manager.hasEncryptedDatabase(VaultFile.DECOY)) + assertFalse("the other vault's file is not this one", manager.hasEncryptedDatabase(VaultFile.PRIMARY)) + } + + @Test + fun wipeRemovesEveryFileOfBothVaultsAndNothingElse() { + createFiles(VaultFile.PRIMARY) + createFiles(VaultFile.DECOY) + val unrelated = File(context.getDatabasePath("other.db").path).apply { writeBytes(byteArrayOf(1)) } + + manager.wipe() + + (files(VaultFile.PRIMARY) + files(VaultFile.DECOY)).forEach { assertFalse(it.path, it.exists()) } + assertTrue(unrelated.exists()) + } + + @Test + fun wipeIfClosedOfOneVaultLeavesTheOtherAlone() { + createFiles(VaultFile.PRIMARY) + createFiles(VaultFile.DECOY) + + assertTrue(manager.wipeIfClosed(only = VaultFile.DECOY)) + + files(VaultFile.DECOY).forEach { assertFalse(it.path, it.exists()) } + assertTrue(files(VaultFile.PRIMARY).first().exists()) + } + + @Test + fun wipeIfClosedWithoutAVaultNamedRemovesBoth() { + createFiles(VaultFile.PRIMARY) + createFiles(VaultFile.DECOY) + + assertTrue(manager.wipeIfClosed()) + + (files(VaultFile.PRIMARY) + files(VaultFile.DECOY)).forEach { assertFalse(it.path, it.exists()) } + } +} diff --git a/app/src/test/java/com/pasich/encly/data/datasource/local/SettingsLocalDataSourceTest.kt b/app/src/test/java/com/pasich/encly/data/datasource/local/SettingsLocalDataSourceTest.kt index ccd1dba7..4ca3657e 100644 --- a/app/src/test/java/com/pasich/encly/data/datasource/local/SettingsLocalDataSourceTest.kt +++ b/app/src/test/java/com/pasich/encly/data/datasource/local/SettingsLocalDataSourceTest.kt @@ -4,6 +4,7 @@ import androidx.datastore.preferences.core.PreferenceDataStoreFactory import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.intPreferencesKey import com.pasich.encly.domain.model.FontStyleType +import com.pasich.encly.domain.model.NoteLineSpacing import com.pasich.encly.domain.model.ThemePalette import com.pasich.encly.domain.model.ThemeSettings import com.pasich.encly.domain.model.ThemeType @@ -41,6 +42,35 @@ class SettingsLocalDataSourceTest { fun emptyStoreGivesDefaults() = runTest { assertEquals(ThemeSettings(), source.themeSettingsFlow.first()) assertEquals(FontStyleType.DEFAULT, source.fontStyleFlow.first()) + assertEquals(NoteLineSpacing.DEFAULT, source.noteLineSpacingFlow.first()) + } + + @Test + fun compactLineSpacingRoundTrips() = runTest { + source.setNoteLineSpacing(NoteLineSpacing.COMPACT) + + assertEquals(NoteLineSpacing.COMPACT, SettingsLocalDataSource(dataStore).noteLineSpacingFlow.first()) + source.setNoteLineSpacing(NoteLineSpacing.DEFAULT) + assertEquals(NoteLineSpacing.DEFAULT, source.noteLineSpacingFlow.first()) + } + + @Test + fun storedLineSpacingValuesKeepTheirMeaning() = runTest { + // The preference is the enum's ordinal: 0 and 1 were written by earlier versions. + assertEquals(0, NoteLineSpacing.DEFAULT.ordinal) + assertEquals(1, NoteLineSpacing.COMPACT.ordinal) + assertEquals(2, NoteLineSpacing.COMFORTABLE.ordinal) + val expected = mapOf( + 0 to NoteLineSpacing.DEFAULT, + 1 to NoteLineSpacing.COMPACT, + 2 to NoteLineSpacing.COMFORTABLE, + ) + expected.forEach { (stored, spacing) -> + dataStore.edit { it[intPreferencesKey(NOTE_LINE_SPACING)] = stored } + assertEquals(spacing, source.noteLineSpacingFlow.first()) + } + source.setNoteLineSpacing(NoteLineSpacing.COMFORTABLE) + assertEquals(2, dataStore.data.first()[intPreferencesKey(NOTE_LINE_SPACING)]) } @Test @@ -66,9 +96,11 @@ class SettingsLocalDataSourceTest { it[intPreferencesKey(THEME_PALETTE)] = 99 it[intPreferencesKey(THEME_TYPE)] = -1 it[intPreferencesKey(FONT_STYLE)] = 7 + it[intPreferencesKey(NOTE_LINE_SPACING)] = 7 } assertEquals(ThemeSettings(), source.themeSettingsFlow.first()) assertEquals(FontStyleType.DEFAULT, source.fontStyleFlow.first()) + assertEquals(NoteLineSpacing.DEFAULT, source.noteLineSpacingFlow.first()) } @Test diff --git a/app/src/test/java/com/pasich/encly/data/handoff/InlineHtmlTest.kt b/app/src/test/java/com/pasich/encly/data/handoff/InlineHtmlTest.kt index 3edcff6b..0db44014 100644 --- a/app/src/test/java/com/pasich/encly/data/handoff/InlineHtmlTest.kt +++ b/app/src/test/java/com/pasich/encly/data/handoff/InlineHtmlTest.kt @@ -46,4 +46,17 @@ class InlineHtmlTest { val input = "${Char(0x200B)}a${Char(0x00A0)}b${Char(0xFEFF)}" assertEquals("a b", InlineHtml.toPlainText(input)) } + + @Test + fun onlyKnownTagsGoWhenAskedAndOtherAngleBracketTextStays() { + assertEquals( + "bold link\nMap ad