From 3fd6b34219e50847fd87cf18b71c0183cbd009c6 Mon Sep 17 00:00:00 2001 From: pasichDev Date: Wed, 14 Oct 2026 12:25:33 +0300 Subject: [PATCH 01/18] chore: start next version dependency and security updates --- .github/workflows/android.yml | 6 +- .github/workflows/publish-play.yml | 6 +- .github/workflows/release.yml | 6 +- app/build.gradle.kts | 3 +- .../pasich/encly/core/security/AutoLock.kt | 2 + .../screen/settings/AutoLockSetting.kt | 10 +++ .../main/res/values-de/strings_security.xml | 3 + .../main/res/values-es/strings_security.xml | 3 + .../main/res/values-fr/strings_security.xml | 3 + .../main/res/values-it/strings_security.xml | 3 + .../main/res/values-nl/strings_security.xml | 3 + .../main/res/values-pl/strings_security.xml | 3 + .../main/res/values-pt/strings_security.xml | 3 + .../main/res/values-uk/strings_security.xml | 3 + app/src/main/res/values/strings_security.xml | 3 + .../encly/core/security/AutoLockTest.kt | 10 ++- .../core/security/SessionLockManagerTest.kt | 23 ++++++ build.gradle.kts | 1 - .../plans/2026-10-14-next-version.md | 72 ++++++++++++++++++ gradle/libs.versions.toml | 44 +++++------ gradle/wrapper/gradle-wrapper.jar | Bin 43583 -> 47505 bytes gradle/wrapper/gradle-wrapper.properties | 4 +- gradlew | 13 ++-- gradlew.bat | 36 +++------ 24 files changed, 193 insertions(+), 70 deletions(-) create mode 100644 docs/superpowers/plans/2026-10-14-next-version.md diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index f20b5d27..0c1f4f8a 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 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/app/build.gradle.kts b/app/build.gradle.kts index c8799e77..099f9aef 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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 { 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/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/res/values-de/strings_security.xml b/app/src/main/res/values-de/strings_security.xml index e11dcac7..aaa86b76 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. diff --git a/app/src/main/res/values-es/strings_security.xml b/app/src/main/res/values-es/strings_security.xml index 06ae6976..b3c49bcc 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. diff --git a/app/src/main/res/values-fr/strings_security.xml b/app/src/main/res/values-fr/strings_security.xml index c28516ba..56f93e67 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. diff --git a/app/src/main/res/values-it/strings_security.xml b/app/src/main/res/values-it/strings_security.xml index 22e2043a..76420438 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. diff --git a/app/src/main/res/values-nl/strings_security.xml b/app/src/main/res/values-nl/strings_security.xml index 845d461b..eb1e6d9e 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. diff --git a/app/src/main/res/values-pl/strings_security.xml b/app/src/main/res/values-pl/strings_security.xml index c676477b..351c1f86 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. diff --git a/app/src/main/res/values-pt/strings_security.xml b/app/src/main/res/values-pt/strings_security.xml index 1b9da146..36ae7677 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. diff --git a/app/src/main/res/values-uk/strings_security.xml b/app/src/main/res/values-uk/strings_security.xml index 9b1e2420..5fa80cb6 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-бітний ключ Уся база зашифрована ключем, створеним на цьому телефоні. diff --git a/app/src/main/res/values/strings_security.xml b/app/src/main/res/values/strings_security.xml index 8ab4c270..14acf89f 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. 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/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/build.gradle.kts b/build.gradle.kts index faddbffa..a18cf670 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,7 +1,6 @@ // Plugin versions live in gradle/libs.versions.toml (one Kotlin version for every Kotlin plugin). plugins { alias(libs.plugins.android.application) apply false - alias(libs.plugins.kotlin.android) apply false alias(libs.plugins.kotlin.compose) apply false alias(libs.plugins.kotlin.serialization) apply false alias(libs.plugins.ksp) apply false diff --git a/docs/superpowers/plans/2026-10-14-next-version.md b/docs/superpowers/plans/2026-10-14-next-version.md new file mode 100644 index 00000000..d3c755bd --- /dev/null +++ b/docs/superpowers/plans/2026-10-14-next-version.md @@ -0,0 +1,72 @@ +# Next Version Implementation Plan + +> **For agentic workers:** Work through the checked tasks in order. The decoy vault requires a security design review before implementation. + +**Goal:** Deliver the open dependency updates and issues #59, #60, #61 and #63 as a reviewed next release. + +**Architecture:** Keep existing encrypted-vault behavior as the baseline. Land the build-tool updates together, add the small user preferences without changing stored note data, and design the decoy vault as a genuinely separate encrypted database before connecting it to the lock screen. Weblate remains a contributor service and never becomes an app dependency. + +**Tech Stack:** Kotlin, Jetpack Compose, Room, SQLCipher, Gradle, GitHub Actions, Android resources, Weblate. + +**Spec:** GitHub issues #59, #60, #61, #63 and Dependabot PRs #32, #34–#41, #48, #49, #57, #58. + +## Global Constraints + +- Keep the existing 15-second auto-lock default and immediate locking on screen-off/device lock. +- Keep the app offline; release manifests must not contain `android.permission.INTERNET`. +- Keep strict translation completeness, placeholder and Android lint checks. +- Preserve primary-vault keys, existing encrypted notes, wipe-PIN behavior, and recovery compatibility. +- Do not advertise forensic-proof plausible deniability. + +## Task 1: Dependency and CI updates + +**Files:** `gradle/libs.versions.toml`, `gradle/wrapper/*`, `gradlew`, `gradlew.bat`, `app/build.gradle.kts`, `.github/workflows/{android,publish-play,release}.yml`, and build scripts that need AGP 9 adaptation. + +- [ ] Combine all open Dependabot version changes, including the Gradle 9 / AGP 9 / Hilt set that fails when updated separately. +- [ ] Raise `compileSdk` to 37 for SQLCipher 4.19.0 while keeping the shipping target SDK and minimum SDK unchanged. +- [ ] Adapt Kotlin, KSP, Kover, Hilt and Android build configuration to the final toolchain. +- [ ] Run the existing format, unit test, coverage, detekt, release build, lint and no-INTERNET gates. +- [ ] Exercise an upgrade of an existing encrypted database on an Android device or emulator before considering SQLCipher complete. + +## Task 2: Auto-lock options (#60) + +**Files:** `core/security/AutoLock.kt`, `presentation/screen/settings/AutoLockSetting.kt`, all `strings_security.xml` locales, `AutoLockTest.kt`, `SessionLockManagerTest.kt`. + +- [ ] Add persistent 15- and 30-minute choices to the existing policy and Settings → Security selector. +- [ ] Explain the exposure from a longer unlocked background session beside those choices. +- [ ] Verify persisted values, the unchanged default, timeout boundary, screen-off and device-lock behavior. + +## Task 3: Editor line spacing (#59) + +**Files:** editor settings storage and ViewModel, `EditNoteScreen.kt`, `EditorBlocksHost.kt`, paragraph/list/quote blocks, appearance or editor settings UI, all locale strings and editor tests. + +- [ ] Add a stored Default/Compact selector reachable from the note editor. +- [ ] Apply compact line height to wrapped text and reduce the gap between consecutive paragraph blocks in editing and read-only views. +- [ ] Keep note text and block serialization unchanged; verify selection survives an app restart. + +## Task 4: Decoy-vault security design and implementation (#61) + +**Files:** security RFC, `SecurityManager`, `SessionLockManager`, `SecureDatabaseManager`, `VaultStore`, key-slot handling, repositories/ViewModels, lock screen, settings, security documentation and integration tests. + +- [ ] Write and review the key hierarchy, three-PIN dispatch, crash recovery, database ownership, backup/recovery/biometric rules, migration and forensic limits. +- [ ] Implement a separate random decoy DEK, wrapped key and SQLCipher database; never share an unlocked primary DB or repository handle with a decoy session. +- [ ] Add opt-in setup with fresh primary authentication and collision checks against primary and wipe PINs. +- [ ] Route all three PINs through the existing keypad with uniform visible behavior and retain wipe-PIN destruction semantics. +- [ ] Isolate notes, search, tags, tasks, trash, backup/export controls, recents and pending writes across sessions. +- [ ] Test relock, two-way switching, process death during every setup stage, failed unlock, OS lock, upgrade and downgrade behavior; complete security review. + +## Task 5: Weblate contributor workflow (#63) + +**Files:** Weblate project/components, GitHub integration and branch permissions, README, CONTRIBUTING, translator guidance, glossary and CI smoke tests. + +- [ ] Verify hosted eligibility or choose a maintained hosting arrangement; confirm Android XML and plurals round-trip without data loss. +- [ ] Configure two components using the existing main and security string resource paths and import all supported locales. +- [ ] Connect Weblate to a dedicated translation branch and PR review flow without direct `main` writes. +- [ ] Demonstrate an English-key sync, translation edit, generated PR, complete CI pass and clean resync. +- [ ] Publish translator entry point and maintainer conflict/rollback instructions; keep release validation unchanged. + +## Release gate + +- [ ] Re-read all four issues against the implementation and record any unmet acceptance criterion in the draft PR. +- [ ] Run the full CI command and verify a release artifact without INTERNET permission. +- [ ] Merge only after the security review and the Weblate round trip are complete. diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 7c1f8031..7f0b09f2 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,14 +1,14 @@ # Single source of truth for plugin and library versions. # Kotlin, the Compose compiler plugin and the serialization plugin share one `kotlin` version; -# KSP must match it (-). Dependabot updates this file (see .github/dependabot.yml). +# KSP releases use their own version scheme; keep the Kotlin/KSP pair compatible. [versions] -agp = "8.11.1" -kotlin = "2.2.0" -ksp = "2.2.0-2.0.2" -hilt = "2.56.2" +agp = "9.4.1" +kotlin = "2.4.20" +ksp = "2.3.12" +hilt = "2.60.1" detekt = "1.23.8" -kover = "0.9.1" +kover = "0.9.11" spotless = "8.10.2" # ktlint and the Compose detekt rules are tool artifacts, not app dependencies (see build.gradle.kts). ktlint = "1.8.0" @@ -19,29 +19,29 @@ compose = "1.8.3" material3 = "1.3.2" lifecycle = "2.9.1" activityCompose = "1.10.1" -appcompat = "1.7.1" -navigationCompose = "2.9.0" -hiltNavigationCompose = "1.2.0" -coreSplashscreen = "1.0.1" -coreKtx = "1.16.0" -room = "2.7.2" -coroutines = "1.10.2" +appcompat = "1.8.0" +navigationCompose = "2.10.2" +hiltNavigationCompose = "1.4.0" +coreSplashscreen = "1.2.0" +coreKtx = "1.19.1" +room = "2.8.5" +coroutines = "1.11.0" biometric = "1.1.0" -reorderable = "2.5.1" -gson = "2.13.1" +reorderable = "3.1.0" +gson = "2.14.0" datastore = "1.1.7" -serializationJson = "1.9.0" -sqlcipher = "4.17.0" +serializationJson = "1.11.0" +sqlcipher = "4.19.0" sqliteKtx = "2.7.0" bip39 = "1.0.9" iconsLucide = "1.1.0" junit = "4.13.2" -androidxTestCore = "1.6.1" -androidxTestRunner = "1.6.2" -androidxTestExtJunit = "1.2.1" -mockito = "5.18.0" -robolectric = "4.16.1" +androidxTestCore = "1.7.0" +androidxTestRunner = "1.7.0" +androidxTestExtJunit = "1.3.0" +mockito = "5.23.0" +robolectric = "4.17" [libraries] androidx-compose-ui = { module = "androidx.compose.ui:ui", version.ref = "compose" } diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index a4b76b9530d66f5e68d973ea569d8e19de379189..eddabd2eef8d94a5437d6168ff9c87a78ff725b3 100644 GIT binary patch delta 39486 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 delta 35650 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 echo Please set the JAVA_HOME variable in your environment to match the 1>&2 echo location of your Java installation. 1>&2 -goto fail +"%COMSPEC%" /c exit 1 :findJavaFromJavaHome set JAVA_HOME=%JAVA_HOME:"=% @@ -65,30 +65,18 @@ echo. 1>&2 echo Please set the JAVA_HOME variable in your environment to match the 1>&2 echo location of your Java installation. 1>&2 -goto fail +"%COMSPEC%" /c exit 1 :execute @rem Setup the command line -set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar -@rem Execute Gradle -"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* +@rem Execute gradlew +@rem endlocal doesn't take effect until after the line is parsed and variables are expanded +@rem which allows us to clear the local environment before executing the java command +endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel -:end -@rem End local scope for the variables with windows NT shell -if %ERRORLEVEL% equ 0 goto mainEnd - -:fail -rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of -rem the _cmd.exe /c_ return code! -set EXIT_CODE=%ERRORLEVEL% -if %EXIT_CODE% equ 0 set EXIT_CODE=1 -if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% -exit /b %EXIT_CODE% - -:mainEnd -if "%OS%"=="Windows_NT" endlocal - -:omega +:exitWithErrorLevel +@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts +"%COMSPEC%" /c exit %ERRORLEVEL% From 9433e1b25441ec19cdb38d6f5aadb6d24c39b267 Mon Sep 17 00:00:00 2001 From: pasichDev Date: Wed, 14 Oct 2026 12:26:57 +0300 Subject: [PATCH 02/18] chore: keep release planning outside repository --- .../plans/2026-10-14-next-version.md | 72 ------------------- 1 file changed, 72 deletions(-) delete mode 100644 docs/superpowers/plans/2026-10-14-next-version.md diff --git a/docs/superpowers/plans/2026-10-14-next-version.md b/docs/superpowers/plans/2026-10-14-next-version.md deleted file mode 100644 index d3c755bd..00000000 --- a/docs/superpowers/plans/2026-10-14-next-version.md +++ /dev/null @@ -1,72 +0,0 @@ -# Next Version Implementation Plan - -> **For agentic workers:** Work through the checked tasks in order. The decoy vault requires a security design review before implementation. - -**Goal:** Deliver the open dependency updates and issues #59, #60, #61 and #63 as a reviewed next release. - -**Architecture:** Keep existing encrypted-vault behavior as the baseline. Land the build-tool updates together, add the small user preferences without changing stored note data, and design the decoy vault as a genuinely separate encrypted database before connecting it to the lock screen. Weblate remains a contributor service and never becomes an app dependency. - -**Tech Stack:** Kotlin, Jetpack Compose, Room, SQLCipher, Gradle, GitHub Actions, Android resources, Weblate. - -**Spec:** GitHub issues #59, #60, #61, #63 and Dependabot PRs #32, #34–#41, #48, #49, #57, #58. - -## Global Constraints - -- Keep the existing 15-second auto-lock default and immediate locking on screen-off/device lock. -- Keep the app offline; release manifests must not contain `android.permission.INTERNET`. -- Keep strict translation completeness, placeholder and Android lint checks. -- Preserve primary-vault keys, existing encrypted notes, wipe-PIN behavior, and recovery compatibility. -- Do not advertise forensic-proof plausible deniability. - -## Task 1: Dependency and CI updates - -**Files:** `gradle/libs.versions.toml`, `gradle/wrapper/*`, `gradlew`, `gradlew.bat`, `app/build.gradle.kts`, `.github/workflows/{android,publish-play,release}.yml`, and build scripts that need AGP 9 adaptation. - -- [ ] Combine all open Dependabot version changes, including the Gradle 9 / AGP 9 / Hilt set that fails when updated separately. -- [ ] Raise `compileSdk` to 37 for SQLCipher 4.19.0 while keeping the shipping target SDK and minimum SDK unchanged. -- [ ] Adapt Kotlin, KSP, Kover, Hilt and Android build configuration to the final toolchain. -- [ ] Run the existing format, unit test, coverage, detekt, release build, lint and no-INTERNET gates. -- [ ] Exercise an upgrade of an existing encrypted database on an Android device or emulator before considering SQLCipher complete. - -## Task 2: Auto-lock options (#60) - -**Files:** `core/security/AutoLock.kt`, `presentation/screen/settings/AutoLockSetting.kt`, all `strings_security.xml` locales, `AutoLockTest.kt`, `SessionLockManagerTest.kt`. - -- [ ] Add persistent 15- and 30-minute choices to the existing policy and Settings → Security selector. -- [ ] Explain the exposure from a longer unlocked background session beside those choices. -- [ ] Verify persisted values, the unchanged default, timeout boundary, screen-off and device-lock behavior. - -## Task 3: Editor line spacing (#59) - -**Files:** editor settings storage and ViewModel, `EditNoteScreen.kt`, `EditorBlocksHost.kt`, paragraph/list/quote blocks, appearance or editor settings UI, all locale strings and editor tests. - -- [ ] Add a stored Default/Compact selector reachable from the note editor. -- [ ] Apply compact line height to wrapped text and reduce the gap between consecutive paragraph blocks in editing and read-only views. -- [ ] Keep note text and block serialization unchanged; verify selection survives an app restart. - -## Task 4: Decoy-vault security design and implementation (#61) - -**Files:** security RFC, `SecurityManager`, `SessionLockManager`, `SecureDatabaseManager`, `VaultStore`, key-slot handling, repositories/ViewModels, lock screen, settings, security documentation and integration tests. - -- [ ] Write and review the key hierarchy, three-PIN dispatch, crash recovery, database ownership, backup/recovery/biometric rules, migration and forensic limits. -- [ ] Implement a separate random decoy DEK, wrapped key and SQLCipher database; never share an unlocked primary DB or repository handle with a decoy session. -- [ ] Add opt-in setup with fresh primary authentication and collision checks against primary and wipe PINs. -- [ ] Route all three PINs through the existing keypad with uniform visible behavior and retain wipe-PIN destruction semantics. -- [ ] Isolate notes, search, tags, tasks, trash, backup/export controls, recents and pending writes across sessions. -- [ ] Test relock, two-way switching, process death during every setup stage, failed unlock, OS lock, upgrade and downgrade behavior; complete security review. - -## Task 5: Weblate contributor workflow (#63) - -**Files:** Weblate project/components, GitHub integration and branch permissions, README, CONTRIBUTING, translator guidance, glossary and CI smoke tests. - -- [ ] Verify hosted eligibility or choose a maintained hosting arrangement; confirm Android XML and plurals round-trip without data loss. -- [ ] Configure two components using the existing main and security string resource paths and import all supported locales. -- [ ] Connect Weblate to a dedicated translation branch and PR review flow without direct `main` writes. -- [ ] Demonstrate an English-key sync, translation edit, generated PR, complete CI pass and clean resync. -- [ ] Publish translator entry point and maintainer conflict/rollback instructions; keep release validation unchanged. - -## Release gate - -- [ ] Re-read all four issues against the implementation and record any unmet acceptance criterion in the draft PR. -- [ ] Run the full CI command and verify a release artifact without INTERNET permission. -- [ ] Merge only after the security review and the Weblate round trip are complete. From 128adaaf4c1662de84849fbf5a8d50e7f866982c Mon Sep 17 00:00:00 2001 From: pasichDev Date: Wed, 14 Oct 2026 13:10:20 +0300 Subject: [PATCH 03/18] feat: add compact note spacing and complete dependency integration --- .../encly/core/security/SessionLockManager.kt | 2 +- .../local/SettingsLocalDataSource.kt | 14 +++++ .../data/repository/SettingsRepositoryImpl.kt | 5 ++ .../encly/domain/model/NoteLineSpacing.kt | 7 +++ .../domain/repository/SettingsRepository.kt | 4 ++ .../presentation/components/HomeTaskWidget.kt | 4 +- .../components/editNote/NoteBottomBar.kt | 4 +- .../components/tasks/SubtaskTree.kt | 57 ++++++++++--------- .../encly/presentation/designsystem/Editor.kt | 3 +- .../dialogs/EditNoteBottomSheet.kt | 19 ++++++- .../dialogs/NoteCardBottomSheet.kt | 4 +- .../dialogs/NotesSortBottomSheet.kt | 4 +- .../dialogs/tasks/SubtaskEditor.kt | 18 +++--- .../encly/presentation/drawer/MainDrawer.kt | 4 +- .../presentation/editor/EditorBlocksHost.kt | 4 +- .../presentation/editor/blocks/ListBlock.kt | 4 ++ .../presentation/editor/blocks/QuoteBlock.kt | 7 +++ .../presentation/editor/blocks/TextBlock.kt | 4 ++ .../encly/presentation/navigation/NavHost.kt | 6 +- .../presentation/screen/EditTagScreen.kt | 4 +- .../encly/presentation/screen/LockScreen.kt | 6 +- .../encly/presentation/screen/MainScreen.kt | 12 ++-- .../presentation/screen/NotesListScreen.kt | 4 +- .../screen/PinCodeConfigScreen.kt | 4 +- .../presentation/screen/TagsListScreen.kt | 4 +- .../encly/presentation/screen/TasksScreen.kt | 4 +- .../screen/backup/BackupScreen.kt | 4 +- .../screen/editnote/EditNoteLocalProvider.kt | 6 ++ .../screen/editnote/EditNoteScreen.kt | 38 ++++++++----- .../screen/editnote/EditorEffects.kt | 8 +-- .../screen/editnote/EditorSheets.kt | 8 +-- .../screen/handoff/ImportFromMyNotesScreen.kt | 4 +- .../screen/onboarding/OnboardingScreen.kt | 6 +- .../screen/settings/AppearanceScreen.kt | 4 +- .../screen/settings/SecuritySettingsScreen.kt | 13 +++-- .../screen/settings/SettingsScreen.kt | 4 +- .../screen/settings/WipePinScreen.kt | 13 ++--- .../screen/trash/TrashNotesList.kt | 4 +- .../presentation/screen/trash/TrashScreen.kt | 4 +- .../viewmodel/EditNoteViewModel.kt | 11 ++++ .../viewmodel/PreloadedViewModelOwner.kt | 26 +++++++++ .../java/com/pasich/encly/ui/theme/Theme.kt | 4 +- .../com/pasich/encly/utils/DataFormater.kt | 4 +- app/src/main/res/values-de/strings.xml | 3 + app/src/main/res/values-es/strings.xml | 3 + app/src/main/res/values-fr/strings.xml | 3 + app/src/main/res/values-it/strings.xml | 3 + app/src/main/res/values-nl/strings.xml | 3 + app/src/main/res/values-pl/strings.xml | 3 + app/src/main/res/values-pt/strings.xml | 3 + app/src/main/res/values-uk/strings.xml | 3 + app/src/main/res/values/strings.xml | 3 + .../local/SettingsLocalDataSourceTest.kt | 13 +++++ .../encly/i18n/TranslationCompletenessTest.kt | 32 +++++++++++ .../encly/testutil/TestSettingsRepository.kt | 6 ++ .../encly/ui/screens/ComposeScreenTest.kt | 3 +- .../java/com/pasich/encly/ui/screens/Fakes.kt | 7 +++ config/detekt/detekt.yml | 1 + gradle.properties | 2 +- 59 files changed, 336 insertions(+), 128 deletions(-) create mode 100644 app/src/main/java/com/pasich/encly/domain/model/NoteLineSpacing.kt create mode 100644 app/src/main/java/com/pasich/encly/presentation/viewmodel/PreloadedViewModelOwner.kt 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/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/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/domain/model/NoteLineSpacing.kt b/app/src/main/java/com/pasich/encly/domain/model/NoteLineSpacing.kt new file mode 100644 index 00000000..e79a9881 --- /dev/null +++ b/app/src/main/java/com/pasich/encly/domain/model/NoteLineSpacing.kt @@ -0,0 +1,7 @@ +package com.pasich.encly.domain.model + +/** Editor text spacing, stored by ordinal. Append new choices without reordering these values. */ +enum class NoteLineSpacing { + DEFAULT, + COMPACT, +} 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..276fe2fd 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) } 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/Editor.kt b/app/src/main/java/com/pasich/encly/presentation/designsystem/Editor.kt index 82aacb5d..fce991b4 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 @@ -107,7 +107,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(compactText: Boolean = false): Modifier = + padding(horizontal = BlockBleed, vertical = if (compactText) 0.dp else BlockInset) /** A thin vertical rule between groups of toolbar buttons. */ @Composable 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..ad440de4 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,15 +5,18 @@ 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, @@ -31,10 +34,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,6 +46,17 @@ fun EditNoteBottomSheet( currentSize = fontSize, onSizeChange = { newSize -> viewModel.updateFontSize(newSize) }, ) + SectionOverline(stringResource(R.string.note_line_spacing_title)) + EnclyRadioRow( + title = stringResource(R.string.note_line_spacing_default), + selected = lineSpacing == NoteLineSpacing.DEFAULT, + onClick = { viewModel.updateNoteLineSpacing(NoteLineSpacing.DEFAULT) }, + ) + EnclyRadioRow( + title = stringResource(R.string.note_line_spacing_compact), + selected = lineSpacing == NoteLineSpacing.COMPACT, + onClick = { viewModel.updateNoteLineSpacing(NoteLineSpacing.COMPACT) }, + ) EnclyGroupDivider() EnclySheetRow( title = stringResource(id = R.string.duplicate), 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/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/EditorBlocksHost.kt b/app/src/main/java/com/pasich/encly/presentation/editor/EditorBlocksHost.kt index 10266ae5..79a9c75d 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 @@ -24,6 +24,7 @@ import androidx.compose.ui.semantics.customActions import androidx.compose.ui.semantics.semantics import com.pasich.encly.R import com.pasich.encly.domain.enums.BottomSheetsOpenType +import com.pasich.encly.domain.model.NoteLineSpacing import com.pasich.encly.dynamicBlocks.Block import com.pasich.encly.presentation.designsystem.editorBlockFrame import com.pasich.encly.presentation.editor.blocks.HBlock @@ -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 @@ -149,7 +151,7 @@ private fun EditorBlock( !focusState.hasFocus -> callbacks.onFocusLost() } } - .editorBlockFrame(), + .editorBlockFrame(block is Block.TextBlock && LocalNoteLineSpacing.current == NoteLineSpacing.COMPACT), ) { BlockContent( block = block, 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..1d91ce51 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 @@ -42,6 +42,7 @@ 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.domain.model.NoteLineSpacing import com.pasich.encly.dynamicBlocks.Block import com.pasich.encly.dynamicBlocks.BlockType import com.pasich.encly.presentation.designsystem.CheckboxSize @@ -49,6 +50,7 @@ import com.pasich.encly.presentation.designsystem.EnclyCheckbox 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 @@ -279,10 +281,12 @@ private class ListEditor( @Composable private fun listTextStyle(): TextStyle { val fontStyles = rememberFontStyles() + val compact = LocalNoteLineSpacing.current == NoteLineSpacing.COMPACT return MaterialTheme.typography.bodyLarge.copy( color = MaterialTheme.colorScheme.onSurface, fontFamily = fontStyles.families.body, fontSize = fontStyles.sizes.list, + lineHeight = if (compact) fontStyles.sizes.list else MaterialTheme.typography.bodyLarge.lineHeight, ) } 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..200cdb54 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 @@ -24,9 +24,11 @@ import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.TextStyle import com.pasich.encly.R +import com.pasich.encly.domain.model.NoteLineSpacing import com.pasich.encly.dynamicBlocks.Block import com.pasich.encly.presentation.designsystem.EnclyIcons 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 @@ -54,6 +56,11 @@ fun QuoteBlock( val quoteStyle = EnclyTheme.typography.quote.copy( color = MaterialTheme.colorScheme.onSurface, fontSize = fontStyles.sizes.quote, + lineHeight = if (LocalNoteLineSpacing.current == NoteLineSpacing.COMPACT) { + fontStyles.sizes.quote + } else { + EnclyTheme.typography.quote.lineHeight + }, ) // A tonal card between an opening and a closing quote mark in `primary`, 8 dp below the block 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..61fec877 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 @@ -15,8 +15,10 @@ import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.input.KeyboardType +import com.pasich.encly.domain.model.NoteLineSpacing import com.pasich.encly.dynamicBlocks.Block import com.pasich.encly.presentation.editor.BlockActions +import com.pasich.encly.presentation.screen.editnote.LocalNoteLineSpacing import com.pasich.encly.presentation.screen.editnote.rememberFontStyles /** @@ -39,11 +41,13 @@ fun TextBlock( showPlaceholder: Boolean = true, ) { val fontStyles = rememberFontStyles() + val compact = LocalNoteLineSpacing.current == NoteLineSpacing.COMPACT 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 = if (compact) fontStyles.sizes.textBlock else MaterialTheme.typography.bodyLarge.lineHeight, ) val actions by rememberUpdatedState(blockActions) val input = remember { BlockInput(BlockField.Paragraph) { actions } } 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..21cab1f9 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 @@ -33,6 +32,7 @@ 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) { @@ -173,7 +173,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 +195,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/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..885eb089 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,7 +37,6 @@ 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.SensitiveDataCleaner @@ -53,6 +52,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 @@ -169,7 +169,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..ad20ec2d 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,6 +8,7 @@ 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.ui.theme.fontSet import com.pasich.encly.utils.FontSizeUtils @@ -38,6 +39,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 +55,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,6 +68,7 @@ fun EditNoteSettingsProvider( LocalBaseFontSize provides baseFontSize, LocalFontStyle provides fontStyle, LocalSimpleEdit provides simpleEdit, + LocalNoteLineSpacing provides lineSpacing, LocalFontStyles provides fontStyles, 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..f0638156 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 @@ -28,17 +28,18 @@ 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.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 import androidx.navigation.NavHostController import com.pasich.encly.R +import com.pasich.encly.domain.model.NoteLineSpacing import com.pasich.encly.presentation.components.editNote.NoteBottomBar import com.pasich.encly.presentation.components.editNote.NoteOverline import com.pasich.encly.presentation.components.editNote.TitleField @@ -56,6 +57,7 @@ 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 +65,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,6 +92,7 @@ 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() @@ -105,7 +109,12 @@ fun EditNoteScreen( BackHandler { exits.back() } 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,7 +149,7 @@ 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() @@ -195,7 +204,7 @@ private fun EditorOverlays( overlays: EditorOverlaysState, exits: EditorExits, onManageTags: () -> Unit, - viewModel: EditNoteViewModel = hiltViewModel(), + viewModel: EditNoteViewModel = enclyViewModel(), ) { val lockEditor by viewModel.lockEditor.collectAsState() EditorConfirmDialogs( @@ -243,8 +252,9 @@ private fun EditorContent( listState: LazyListState, titleFocus: FocusRequester, modifier: Modifier = Modifier, - viewModel: EditNoteViewModel = hiltViewModel(), + viewModel: EditNoteViewModel = enclyViewModel(), ) { + val compactText = LocalNoteLineSpacing.current == NoteLineSpacing.COMPACT val lockEditor by viewModel.lockEditor.collectAsState() val contentLoadFailed by viewModel.contentLoadFailed.collectAsState() val editingLinkIds by viewModel.editingLinkIds.collectAsState() @@ -256,7 +266,7 @@ private fun EditorContent( 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), + verticalArrangement = Arrangement.spacedBy(if (compactText) 0.dp else EnclyTheme.spacing.labelGap), contentPadding = PaddingValues(top = EnclyTheme.spacing.xxs), modifier = modifier.fillMaxSize(), ) { @@ -287,7 +297,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 +313,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..f8f9750e 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 @@ -15,13 +15,13 @@ import androidx.compose.runtime.setValue import androidx.compose.runtime.withFrameNanos import androidx.compose.ui.focus.FocusRequester 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 private const val INITIAL_FOCUS_FRAMES = 5 @@ -31,7 +31,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 +52,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 +89,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) { 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..eaf4f388 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() @@ -111,8 +111,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/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/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/SecuritySettingsScreen.kt b/app/src/main/java/com/pasich/encly/presentation/screen/settings/SecuritySettingsScreen.kt index e5f99d87..f6845c98 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 @@ -26,7 +26,6 @@ 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 @@ -50,6 +49,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 +57,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() @@ -211,13 +211,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) { 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..b0be1afc 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() 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..2e310f78 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 @@ -26,10 +26,8 @@ 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 @@ -49,6 +47,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.launch @@ -179,7 +178,7 @@ private class WipePinState { fun WipePinScreen( navController: NavHostController, modifier: Modifier = Modifier, - securityViewModel: SecuritySettingsViewModel = hiltViewModel(), + securityViewModel: SecuritySettingsViewModel = enclyViewModel(), ) { val state = remember { WipePinState() } val securityState by securityViewModel.uiState.collectAsStateWithLifecycle() @@ -241,14 +240,14 @@ fun WipePinScreen( /** 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 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) { - val id = message ?: return@LaunchedEffect + LaunchedEffect(message, messageText) { + val text = messageText ?: return@LaunchedEffect currentOnShow() - snackbarScope.launch { snackbarHostState.showSnackbar(context.getString(id)) } + snackbarScope.launch { snackbarHostState.showSnackbar(text) } } } 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/EditNoteViewModel.kt b/app/src/main/java/com/pasich/encly/presentation/viewmodel/EditNoteViewModel.kt index 58b43d1a..aba99c03 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 @@ -150,6 +151,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), @@ -260,6 +267,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 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/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..225ebe58 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -148,6 +148,9 @@ Encly meldet einen beschädigten Tresor Textgröße + Zeilenabstand + Standard + Kompakt Redaktionell Modern Technisch diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index a7e69fcc..d2c8a110 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -148,6 +148,9 @@ Encly indica que la caja fuerte está dañada Tamaño del texto + Interlineado + Predeterminado + Compacto Editorial Moderno Técnico diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index ac7e5e51..4499adb1 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -148,6 +148,9 @@ Encly indique que le coffre-fort est endommagé Taille du texte + Espacement des lignes + Par défaut + Compact Éditorial Moderne Technique diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 900166ce..1fb03851 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -148,6 +148,9 @@ Encly segnala che la cassaforte è danneggiata Dimensione del testo + Interlinea + Predefinita + Compatta Editoriale Moderno Tecnico diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index 55fc7e36..fe057181 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -148,6 +148,9 @@ Encly meldt dat de kluis beschadigd is Tekstgrootte + Regelafstand + Standaard + Compact Redactioneel Modern Technisch diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 329ea29f..748da1fb 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -148,6 +148,9 @@ Encly zgłasza uszkodzenie sejfu Rozmiar tekstu + Odstępy między wierszami + Domyślne + Kompaktowe Redakcyjny Nowoczesny Techniczny diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index 44453b8f..872de13c 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -148,6 +148,9 @@ O Encly informa que o cofre está danificado Tamanho do texto + Espaçamento entre linhas + Padrão + Compacto Editorial Moderno Técnico diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index 700c428e..7a76e963 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -148,6 +148,9 @@ Encly повідомляє про пошкодження сховища Розмір тексту + Міжряддя + Звичайне + Компактне Редакційний Сучасний Технічний diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 09e3a3dc..c06761dd 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -164,6 +164,9 @@ Encly says the vault is damaged Text size + Line spacing + Default + Compact Editorial Modern Technical 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..c1b1c54a 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,16 @@ 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 @@ -66,9 +77,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/i18n/TranslationCompletenessTest.kt b/app/src/test/java/com/pasich/encly/i18n/TranslationCompletenessTest.kt index 3c42dc6b..d756ad2e 100644 --- a/app/src/test/java/com/pasich/encly/i18n/TranslationCompletenessTest.kt +++ b/app/src/test/java/com/pasich/encly/i18n/TranslationCompletenessTest.kt @@ -49,6 +49,22 @@ class TranslationCompletenessTest { } } + @Test + fun translationComponentFilesKeepTheirOwnKeys() { + val base = File(resDir, "values") + TRANSLATION_COMPONENTS.forEach { filename -> + val source = componentKeys(File(base, filename)) + assertTrue("$filename has no translatable entries", source.isNotEmpty()) + localeDirs.forEach { (locale, dir) -> + assertEquals( + "values-$locale/$filename changed component keys or resource types", + source, + componentKeys(File(dir, filename)), + ) + } + } + } + @Test fun formatArgumentsMatchTheDefaultLocale() { localeDirs.forEach { (locale, dir) -> @@ -154,6 +170,21 @@ class TranslationCompletenessTest { private fun readElements(file: File): List = DocumentBuilderFactory.newInstance().newDocumentBuilder().parse(file).documentElement.children() + private fun componentKeys(file: File): Set> { + val entries = readElements(file) + .filter { it.getAttribute("translatable") != "false" } + .filter { it.tagName in setOf("string", "plurals", "string-array") } + assertTrue( + "$file has string-array entries unsupported by the Weblate Android format", + entries.none { + it.tagName == "string-array" + }, + ) + val keys = entries.map { it.tagName to it.getAttribute("name") } + assertEquals("$file has duplicate resource keys", keys.size, keys.toSet().size) + return keys.toSet() + } + /** An array of @string references is localized through those strings, so it is skipped. */ private fun literalItems(array: Element): List? = array.children().map { it.textContent.trim() } .takeUnless { items -> items.all { it.startsWith("@string/") } } @@ -167,6 +198,7 @@ class TranslationCompletenessTest { private companion object { const val DEFAULT_LOCALE = "en" const val MIN_EXPECTED_KEYS = 100 + val TRANSLATION_COMPONENTS = listOf("strings.xml", "strings_security.xml") val LOCALE_DIR = Regex("""values-[a-z]{2,3}(-r[A-Z]{2})?""") val LOCALE_FILTERS = Regex("""localeFilters\s*\+=\s*listOf\(([^)]*)\)""") val FORMAT_ARG = Regex("""%(\d+\$)?[-#+ 0,(]*\d*(\.\d+)?[a-zA-Z%]""") diff --git a/app/src/test/java/com/pasich/encly/testutil/TestSettingsRepository.kt b/app/src/test/java/com/pasich/encly/testutil/TestSettingsRepository.kt index 54a5fa5f..d9f56647 100644 --- a/app/src/test/java/com/pasich/encly/testutil/TestSettingsRepository.kt +++ b/app/src/test/java/com/pasich/encly/testutil/TestSettingsRepository.kt @@ -2,6 +2,7 @@ package com.pasich.encly.testutil 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 @@ -17,6 +18,7 @@ internal class TestSettingsRepository(override var latestThemeSettings: ThemeSet override val simpleEditFlow = MutableStateFlow(false) override val fontSizeFlow = MutableStateFlow(DEFAULT_FONT_SIZE) override val fontStyleFlow = MutableStateFlow(FontStyleType.DEFAULT) + override val noteLineSpacingFlow = MutableStateFlow(NoteLineSpacing.DEFAULT) override suspend fun loadThemeSettings(existingInstall: Boolean): ThemeSettings = themeSettingsFlow.value @@ -57,6 +59,10 @@ internal class TestSettingsRepository(override var latestThemeSettings: ThemeSet themeSettingsFlow.value = themeSettingsFlow.value.copy(fontStyle = value) } + override suspend fun setNoteLineSpacing(value: NoteLineSpacing) { + noteLineSpacingFlow.value = value + } + private companion object { const val DEFAULT_FONT_SIZE = 16 } diff --git a/app/src/test/java/com/pasich/encly/ui/screens/ComposeScreenTest.kt b/app/src/test/java/com/pasich/encly/ui/screens/ComposeScreenTest.kt index cbaf4cd9..40968275 100644 --- a/app/src/test/java/com/pasich/encly/ui/screens/ComposeScreenTest.kt +++ b/app/src/test/java/com/pasich/encly/ui/screens/ComposeScreenTest.kt @@ -33,6 +33,7 @@ import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController import androidx.test.core.app.ApplicationProvider import com.pasich.encly.domain.model.ThemeSettings +import com.pasich.encly.presentation.viewmodel.PreloadedViewModelOwner import com.pasich.encly.ui.theme.EnclyTheme import kotlinx.coroutines.flow.StateFlow import org.junit.After @@ -242,7 +243,7 @@ abstract class ComposeScreenTest { * A [ViewModelStoreOwner] holding ready-made ViewModels. It deliberately has no default factory: * `hiltViewModel()` then asks the store directly and gets the instance put here. */ -class FakeViewModels(vararg viewModels: ViewModel) : ViewModelStoreOwner { +class FakeViewModels(vararg viewModels: ViewModel) : PreloadedViewModelOwner { override val viewModelStore = ViewModelStore() init { diff --git a/app/src/test/java/com/pasich/encly/ui/screens/Fakes.kt b/app/src/test/java/com/pasich/encly/ui/screens/Fakes.kt index 4dc7a88a..cd18c98d 100644 --- a/app/src/test/java/com/pasich/encly/ui/screens/Fakes.kt +++ b/app/src/test/java/com/pasich/encly/ui/screens/Fakes.kt @@ -5,6 +5,7 @@ import com.pasich.encly.data.model.Tag import com.pasich.encly.data.model.Task 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 @@ -23,6 +24,7 @@ internal class FakeSettingsRepository : SettingsRepository { val showTasks = MutableStateFlow(true) val simpleEdit = MutableStateFlow(false) val fontSize = MutableStateFlow(DEFAULT_FONT_SIZE) + val noteLineSpacing = MutableStateFlow(NoteLineSpacing.DEFAULT) override val themeSettingsFlow: Flow = theme override val isGridNoteList: Flow = grid @@ -31,6 +33,7 @@ internal class FakeSettingsRepository : SettingsRepository { override val simpleEditFlow: Flow = simpleEdit override val fontSizeFlow: Flow = fontSize override val fontStyleFlow: Flow = theme.map { it.fontStyle } + override val noteLineSpacingFlow: Flow = noteLineSpacing override val latestThemeSettings: ThemeSettings get() = theme.value override suspend fun loadThemeSettings(existingInstall: Boolean): ThemeSettings = theme.value @@ -59,6 +62,10 @@ internal class FakeSettingsRepository : SettingsRepository { override suspend fun setFontStyle(value: FontStyleType) = theme.update { it.copy(fontStyle = value) } + override suspend fun setNoteLineSpacing(value: NoteLineSpacing) { + noteLineSpacing.value = value + } + private companion object { const val DEFAULT_FONT_SIZE = 16 } diff --git a/config/detekt/detekt.yml b/config/detekt/detekt.yml index 0585a45a..01c8a686 100644 --- a/config/detekt/detekt.yml +++ b/config/detekt/detekt.yml @@ -46,6 +46,7 @@ Compose: - LocalBaseFontSize - LocalFontStyle - LocalSimpleEdit + - LocalNoteLineSpacing - LocalFontStyles # Theme tokens Material's theme does not carry (ui/theme), read through EnclyTheme. - LocalEnclyColors diff --git a/gradle.properties b/gradle.properties index 8d26bc0e..8c9d4823 100644 --- a/gradle.properties +++ b/gradle.properties @@ -19,4 +19,4 @@ org.gradle.parallel=true org.gradle.configureondemand=true org.gradle.unsafe.configuration-cache=true android.nonTransitiveRClass=false -android.nonFinalResIds=false +android.nonFinalResIds=true From 57b060c49fa997e582df8f595683cde9efa1ec57 Mon Sep 17 00:00:00 2001 From: pasichDev Date: Wed, 14 Oct 2026 13:40:49 +0300 Subject: [PATCH 04/18] fix: preserve note header spacing in compact mode Apply compact spacing to textual list and quote blocks, and document the visible changes in the Unreleased changelog. --- CHANGELOG.md | 5 +++++ .../presentation/editor/EditorBlocksHost.kt | 5 ++++- .../presentation/editor/blocks/ListBlock.kt | 4 +++- .../screen/editnote/EditNoteScreen.kt | 21 +++++++++++++++---- 4 files changed, 29 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 49dff853..74fd1ef2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,11 @@ IzzyOnDroid) and used as the GitHub Release notes. ## [Unreleased] +### Added + +- Compact line spacing for notes, with a persistent editor setting; Default preserves the existing layout. +- Optional 15- and 30-minute auto-lock delays, with a warning about longer background sessions. + ## [2.1.0] - 2026-10-05 versionCode 20100. 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 79a9c75d..752a4880 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 @@ -151,7 +151,10 @@ private fun EditorBlock( !focusState.hasFocus -> callbacks.onFocusLost() } } - .editorBlockFrame(block is Block.TextBlock && LocalNoteLineSpacing.current == NoteLineSpacing.COMPACT), + .editorBlockFrame( + LocalNoteLineSpacing.current == NoteLineSpacing.COMPACT && + (block is Block.TextBlock || block is Block.ListBlock || block is Block.QuoteBlock), + ), ) { BlockContent( block = block, 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 1d91ce51..0e3cd3ea 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 @@ -132,7 +132,9 @@ fun ListBlock( ) Column( modifier = modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(if (checklist) 0.dp else EnclyTheme.spacing.xxs), + verticalArrangement = Arrangement.spacedBy( + if (checklist || LocalNoteLineSpacing.current == NoteLineSpacing.COMPACT) 0.dp else EnclyTheme.spacing.xxs, + ), ) { items.forEachIndexed { itemIndex, item -> key(item.id) { 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 f0638156..a4a431cf 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 @@ -255,6 +255,7 @@ private fun EditorContent( viewModel: EditNoteViewModel = enclyViewModel(), ) { val compactText = LocalNoteLineSpacing.current == NoteLineSpacing.COMPACT + val headerGap = if (compactText) EnclyTheme.spacing.labelGap else 0.dp val lockEditor by viewModel.lockEditor.collectAsState() val contentLoadFailed by viewModel.contentLoadFailed.collectAsState() val editingLinkIds by viewModel.editingLinkIds.collectAsState() @@ -265,7 +266,7 @@ 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). + // Default block frames and this gap give 14 dp between blocks (spec §4.4). verticalArrangement = Arrangement.spacedBy(if (compactText) 0.dp else EnclyTheme.spacing.labelGap), contentPadding = PaddingValues(top = EnclyTheme.spacing.xxs), modifier = modifier.fillMaxSize(), @@ -276,14 +277,26 @@ 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, + ), ) } } From bbe9f3ff91f91ec9441fcf0b3c51b8d7e2d0c28a Mon Sep 17 00:00:00 2001 From: pasichDev Date: Wed, 14 Oct 2026 15:13:12 +0300 Subject: [PATCH 05/18] refactor(security): bind vault operations to database session --- .../SecureDatabaseManagerInstrumentedTest.kt | 16 ++++++ .../pasich/encly/data/backup/BackupManager.kt | 19 ++++--- .../encly/data/backup/RoomVaultDataStore.kt | 55 ++++++++++++------- .../encly/data/backup/VaultDataStore.kt | 6 ++ .../encly/data/database/DatabaseProvider.kt | 17 ++++-- .../data/database/SecureDatabaseManager.kt | 13 +++++ .../data/repository/NotesRepositoryImpl.kt | 12 ++-- .../data/repository/TagsRepositoryImpl.kt | 8 +-- .../data/repository/TasksRepositoryImpl.kt | 8 +-- .../encly/data/backup/BackupManagerTest.kt | 25 +++++++++ .../data/repository/DaoRepositoriesTest.kt | 39 ++++++++++++- .../data/repository/RepositoryImplTest.kt | 22 ++++++++ 12 files changed, 188 insertions(+), 52 deletions(-) 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..b7c7040e 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 @@ -58,11 +58,14 @@ 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) } @@ -76,6 +79,19 @@ 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().notesDao().getAllNotes().first().isEmpty()) + } + private suspend fun insertNotes(count: Int) { val dao = manager.getDatabase().notesDao() repeat(count) { dao.insertNote(Note(title = "note $it", value = "body $it")) } 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..af7a102c 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,22 @@ 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()) } + 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) } } @@ -79,7 +84,7 @@ class BackupManager @Inject constructor( /** 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) } + guarded { BackupImporter.import(payload, mode, store.bindToCurrentSession(), tagMatch) } 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..80d597fb 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 { + /** 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 is now open. */ + fun getDatabase(lease: DatabaseLease): AppDatabase + + /** Legacy entry point for backup code; it must be bound to a lease before decoy is enabled. */ fun getDatabase(): 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..f57e8d01 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 @@ -26,6 +26,7 @@ class SecureDatabaseManager @Inject constructor(@param:ApplicationContext privat DatabaseProvider { private var database: AppDatabase? = null private var isUnlocked = false + private var lease: DatabaseLease? = null /** * The passphrase buffer handed to Room's [SupportOpenHelperFactory]. SQLCipher keeps this @@ -40,6 +41,15 @@ class SecureDatabaseManager @Inject constructor(@param:ApplicationContext privat fun hasEncryptedDatabase(): Boolean = SQLCipherUtils.getDatabaseState(context, DB_NAME) == SQLCipherUtils.State.ENCRYPTED + @Synchronized + override fun captureLease(): DatabaseLease? = lease + + @Synchronized + override fun getDatabase(lease: DatabaseLease): AppDatabase = database?.takeIf { + isUnlocked && this.lease === lease + } ?: error("Database session expired") + + @Synchronized override fun getDatabase(): AppDatabase = database?.takeIf { isUnlocked } ?: error("Database accessed before unlock — call unlockDatabase() first") @@ -92,11 +102,13 @@ class SecureDatabaseManager @Inject constructor(@param:ApplicationContext privat database = opened roomPassphrase = passphrase isUnlocked = true + lease = DatabaseLease() true } catch (e: Exception) { openedDatabase?.close() database = null isUnlocked = false + lease = null AppLogger.e(TAG, "Failed to unlock the database", e) false } finally { @@ -119,6 +131,7 @@ class SecureDatabaseManager @Inject constructor(@param:ApplicationContext privat @Synchronized fun reset() { + lease = null database?.close() database = null isUnlocked = false 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/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/test/java/com/pasich/encly/data/backup/BackupManagerTest.kt b/app/src/test/java/com/pasich/encly/data/backup/BackupManagerTest.kt index 8a1c4edb..75a8d907 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 @@ -66,6 +66,31 @@ 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) + } + } + private companion object { const val ROOT_LENGTH = 32 } diff --git a/app/src/test/java/com/pasich/encly/data/repository/DaoRepositoriesTest.kt b/app/src/test/java/com/pasich/encly/data/repository/DaoRepositoriesTest.kt index 890f1057..c0840e85 100644 --- a/app/src/test/java/com/pasich/encly/data/repository/DaoRepositoriesTest.kt +++ b/app/src/test/java/com/pasich/encly/data/repository/DaoRepositoriesTest.kt @@ -1,6 +1,7 @@ package com.pasich.encly.data.repository import com.pasich.encly.data.database.AppDatabase +import com.pasich.encly.data.database.DatabaseLease import com.pasich.encly.data.database.DatabaseProvider import com.pasich.encly.data.database.dao.NotesDao import com.pasich.encly.data.database.dao.TagsDao @@ -9,6 +10,8 @@ import com.pasich.encly.data.model.Note import com.pasich.encly.data.model.NoteWithTag import com.pasich.encly.data.model.Tag import com.pasich.encly.data.model.Task +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.async import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.runBlocking @@ -17,16 +20,25 @@ import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Test import org.mockito.Mockito.mock +import org.mockito.Mockito.never import org.mockito.Mockito.verify import org.mockito.Mockito.`when` -/** The repositories pass reads and writes to the DAO of the database open at call time. */ +/** Repository instances stay bound to the database opening that created them. */ class DaoRepositoriesTest { private val notesDao = mock(NotesDao::class.java) private val tagsDao = mock(TagsDao::class.java) private val tasksDao = mock(TasksDao::class.java) private var database = databaseWith(notesDao, tagsDao, tasksDao) + private var lease = DatabaseLease() private val provider = object : DatabaseProvider { + override fun captureLease(): DatabaseLease = lease + + override fun getDatabase(lease: DatabaseLease): AppDatabase { + if (lease !== this@DaoRepositoriesTest.lease) error("Database session expired") + return database + } + override fun getDatabase(): AppDatabase = database } @@ -99,15 +111,36 @@ class DaoRepositoriesTest { } @Test - fun eachCallUsesTheDatabaseOpenAtThatMoment(): Unit = runBlocking { + fun oldRepositoryCannotUseANewDatabaseOpening(): Unit = runBlocking { val reopened = mock(TasksDao::class.java) `when`(reopened.deleteTaskById(1)).thenReturn(1) database = databaseWith(notesDao, tagsDao, reopened) + lease = DatabaseLease() - assertTrue(tasks.deleteTaskById(1).isSuccess) + assertTrue(tasks.deleteTaskById(1).isFailure) + assertTrue(TasksRepositoryImpl(provider).deleteTaskById(1).isSuccess) verify(reopened).deleteTaskById(1) } + @Test + fun queuedWriteAndLateFlowCannotCrossIntoTheNextOpening(): Unit = runBlocking { + val note = Note(title = "old session") + val oldFlow = notes.getTrashNotes() + val release = CompletableDeferred() + val queued = async { + release.await() + notes.insertNote(note) + } + val nextNotes = mock(NotesDao::class.java) + database = databaseWith(nextNotes, tagsDao, tasksDao) + lease = DatabaseLease() + release.complete(Unit) + + assertTrue(queued.await().isFailure) + assertTrue(runCatching { oldFlow.first() }.isFailure) + verify(nextNotes, never()).insertNote(note) + } + private fun databaseWith(notes: NotesDao, tags: TagsDao, tasks: TasksDao) = mock(AppDatabase::class.java).also { `when`(it.notesDao()).thenReturn(notes) `when`(it.tagsDao()).thenReturn(tags) diff --git a/app/src/test/java/com/pasich/encly/data/repository/RepositoryImplTest.kt b/app/src/test/java/com/pasich/encly/data/repository/RepositoryImplTest.kt index 47af9c3b..6da24b04 100644 --- a/app/src/test/java/com/pasich/encly/data/repository/RepositoryImplTest.kt +++ b/app/src/test/java/com/pasich/encly/data/repository/RepositoryImplTest.kt @@ -1,6 +1,7 @@ package com.pasich.encly.data.repository import com.pasich.encly.data.database.AppDatabase +import com.pasich.encly.data.database.DatabaseLease import com.pasich.encly.data.database.DatabaseProvider import com.pasich.encly.data.database.dao.NotesDao import com.pasich.encly.data.database.dao.TagsDao @@ -32,7 +33,15 @@ class RepositoryImplTest { `when`(it.tasksDao()).thenReturn(tasksDao) } private var locked = false + private var lease = DatabaseLease() private val provider = object : DatabaseProvider { + override fun captureLease(): DatabaseLease? = if (locked) null else lease + + override fun getDatabase(lease: DatabaseLease): AppDatabase { + if (locked || lease !== this@RepositoryImplTest.lease) error("vault locked") + return database + } + override fun getDatabase(): AppDatabase { if (locked) error("vault locked") return database @@ -70,6 +79,19 @@ class RepositoryImplTest { assertTrue(TasksRepositoryImpl(provider).deleteTaskById(TASK_ID).isFailure) } + @Test + fun repositoryCreatedWhileLockedCannotAttachToALaterOpening() = runBlocking { + locked = true + val stale = NotesRepositoryImpl(provider) + locked = false + lease = DatabaseLease() + val note = Note(title = "new session") + `when`(notesDao.insertNote(note)).thenReturn(NOTE_ID) + + assertTrue(stale.insertNote(note).isFailure) + assertEquals(NOTE_ID, NotesRepositoryImpl(provider).insertNote(note).getOrNull()) + } + @Test fun cancellationIsRethrownNotReportedAsAFailure() { val note = Note(id = NOTE_ID) From ad3080f1ac434d4be73b02c759adc307471eec97 Mon Sep 17 00:00:00 2001 From: pasichdev Date: Wed, 14 Oct 2026 22:22:29 +0300 Subject: [PATCH 06/18] docs: translate Encly on Hosted Weblate Refs: #63 --- CONTRIBUTING.md | 29 ++++++++++++++++++++++++----- README.md | 6 +++++- 2 files changed, 29 insertions(+), 6 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 82599512..8c9b0c4a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -104,10 +104,28 @@ 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) and a `strings_security.xml` (security settings). - -**Fixing a translation:** edit the string in `values-/strings.xml` (or -`strings_security.xml`) and open a PR. Keep the key; only change the text. +(most of the UI), a `strings_security.xml` (security settings) and a `strings_handoff.xml` (the +import from My Notes). + +**Translating on Weblate (recommended):** the project is on +[Hosted Weblate](https://hosted.weblate.org/projects/encly/), one component per file (*App*, +*Security*, *Handoff*). Sign in, pick a language and translate in the browser; no Git or Android +Studio needed. English source strings can only be changed in this repository, not on Weblate. +Weblate collects the changes and opens a pull request from its `weblate-translations` branch; +it never pushes to `main`. That pull request runs the same CI as any other, so it is merged only +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 +`main` on its own. + +**For maintainers:** Weblate pulls `main` on every push (GitHub webhook +`https://hosted.weblate.org/hooks/github/`). Merge its pull request like any other, preferably +before changing the same strings in a branch of your own; on a conflict, merge `main` first, +then use *Repository maintenance → Reset* on Weblate, which drops only Weblate's unmerged +changes and re-reads `main`. A language started on Weblate also needs steps 2–4 below before its +pull request can pass CI. **Adding a string (code change):** add it in English to `values/strings.xml` (or `strings_security.xml`) and to the same file in *every* `values-/`, even if only with an @@ -120,7 +138,8 @@ lower. **Adding a language:** -1. copy `values/strings.xml` and `values/strings_security.xml` to `values-/` and translate them, +1. start it on Weblate, or copy `values/strings.xml`, `values/strings_security.xml` and + `values/strings_handoff.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/README.md b/README.md index 9b5bffe3..c343218c 100644 --- a/README.md +++ b/README.md @@ -41,9 +41,13 @@ To use a different language for Encly only, open **Settings → Language** and p **System default** to follow the device again. The choice applies immediately; on Android 13+ it also appears in the system's per-app language settings. -Found a wrong or clumsy translation, or want to add a language? See +Translations are done on [Hosted Weblate](https://hosted.weblate.org/projects/encly/), which +offers free hosting to open-source projects. Found a wrong or clumsy translation, or want to add +a language? Fix it there, no Git needed, or see [CONTRIBUTING.md → Translations](CONTRIBUTING.md#translations). +[![Translation status](https://hosted.weblate.org/widget/encly/svg-badge.svg)](https://hosted.weblate.org/engage/encly/) + ## Screenshots

From 426af53967fa35d36bc761a52a2393c046a82961 Mon Sep 17 00:00:00 2001 From: pasichdev Date: Wed, 14 Oct 2026 22:22:29 +0300 Subject: [PATCH 07/18] feat(security): add duress PIN with a separate decoy vault A third PIN, typed on the usual lock screen, opens a decoy vault with its own random DEK and SQLCipher database. The duress slot and the decoy key always exist with the same size, every attempt tries all three slots in one KDF run, and a decoy session cannot touch the real vault's key slots, backups or settings. Refs: #61 --- CHANGELOG.md | 5 + SECURITY.md | 71 ++- .../SecureDatabaseManagerInstrumentedTest.kt | 10 +- .../java/com/pasich/encly/MainActivity.kt | 8 +- .../core/security/AuthenticationManager.kt | 448 ++++++++++++----- .../pasich/encly/core/security/DuressSlots.kt | 68 +++ .../pasich/encly/core/security/PinSlots.kt | 120 +++++ .../encly/core/security/SecurityManager.kt | 178 ++++++- .../encly/data/database/DatabaseProvider.kt | 8 +- .../data/database/SecureDatabaseManager.kt | 59 ++- .../components/settings/SettingsBuilder.kt | 6 +- .../presentation/navigation/DecoySession.kt | 36 ++ .../encly/presentation/navigation/NavHost.kt | 11 +- .../presentation/navigation/NavRoutes.kt | 1 + .../screen/PinCodeConfigScreen.kt | 11 +- .../screen/settings/DuressPinScreen.kt | 170 +++++++ .../presentation/screen/settings/PinFlow.kt | 259 ++++++++++ .../screen/settings/SecuritySections.kt | 115 +++++ .../screen/settings/SecuritySettingsScreen.kt | 221 +++------ .../screen/settings/SettingsScreen.kt | 3 + .../screen/settings/WipePinScreen.kt | 306 ++---------- .../viewmodel/SecuritySettingsViewModel.kt | 53 +- .../main/res/values-de/strings_security.xml | 26 + .../main/res/values-es/strings_security.xml | 26 + .../main/res/values-fr/strings_security.xml | 26 + .../main/res/values-it/strings_security.xml | 26 + .../main/res/values-nl/strings_security.xml | 26 + .../main/res/values-pl/strings_security.xml | 26 + .../main/res/values-pt/strings_security.xml | 26 + .../main/res/values-uk/strings_security.xml | 26 + app/src/main/res/values/strings_security.xml | 26 + .../encly/core/security/DuressPinTest.kt | 466 ++++++++++++++++++ .../core/security/SecurityManagerTest.kt | 4 +- .../data/repository/DaoRepositoriesTest.kt | 2 - .../data/repository/RepositoryImplTest.kt | 5 - .../SecuritySettingsViewModelTest.kt | 49 +- .../encly/ui/screens/SecurityScreensTest.kt | 166 ++++++- .../encly/ui/screens/SettingsScreensTest.kt | 14 + .../com/pasich/encly/ui/screens/TestApp.kt | 11 + config/detekt/detekt.yml | 2 + docs/architecture.md | 14 +- 41 files changed, 2516 insertions(+), 618 deletions(-) create mode 100644 app/src/main/java/com/pasich/encly/core/security/DuressSlots.kt create mode 100644 app/src/main/java/com/pasich/encly/core/security/PinSlots.kt create mode 100644 app/src/main/java/com/pasich/encly/presentation/navigation/DecoySession.kt create mode 100644 app/src/main/java/com/pasich/encly/presentation/screen/settings/DuressPinScreen.kt create mode 100644 app/src/main/java/com/pasich/encly/presentation/screen/settings/PinFlow.kt create mode 100644 app/src/test/java/com/pasich/encly/core/security/DuressPinTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 74fd1ef2..0d0bc424 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,11 @@ IzzyOnDroid) and used as the GitHub Release notes. - Compact line spacing for notes, with a persistent editor setting; Default preserves the existing layout. - 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. It does not hide the real vault from a forensic + examination (see SECURITY.md). ## [2.1.0] - 2026-10-05 diff --git a/SECURITY.md b/SECURITY.md index 7164a6d8..67c70b19 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 @@ -137,6 +141,65 @@ 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.** 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. +- **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 clears it like the + PIN. +- **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) turns the duress PIN off like the wipe PIN, and + Settings says so in the real vault; setting it again opens the same decoy notes. +- **Inside the decoy vault.** Notes, tags, tasks, trash, search, sorting and appearance work as + usual. Everything that belongs to the real vault is refused by the security layer and left + out of Settings: 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 cannot be used to test guesses of the + real PIN. 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. @@ -384,7 +447,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 +507,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/src/androidTest/java/com/pasich/encly/data/database/SecureDatabaseManagerInstrumentedTest.kt b/app/src/androidTest/java/com/pasich/encly/data/database/SecureDatabaseManagerInstrumentedTest.kt index b7c7040e..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 }) @@ -67,7 +69,7 @@ class SecureDatabaseManagerInstrumentedTest { 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 @@ -89,11 +91,11 @@ class SecureDatabaseManagerInstrumentedTest { val staleWrite = runCatching { oldDao.insertNote(Note(title = "stale", value = "old session")) } assertTrue(staleWrite.isFailure) - assertTrue(manager.getDatabase().notesDao().getAllNotes().first().isEmpty()) + 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/MainActivity.kt b/app/src/main/java/com/pasich/encly/MainActivity.kt index e461e0dc..0c7a7696 100644 --- a/app/src/main/java/com/pasich/encly/MainActivity.kt +++ b/app/src/main/java/com/pasich/encly/MainActivity.kt @@ -49,6 +49,7 @@ import com.pasich.encly.presentation.effects.LocalUnlockReveal import com.pasich.encly.presentation.effects.UnlockRevealOverlay import com.pasich.encly.presentation.effects.UnlockRevealState import com.pasich.encly.presentation.navigation.AppNavHost +import com.pasich.encly.presentation.navigation.LocalDecoySession import com.pasich.encly.presentation.navigation.LocalEraseEpoch import com.pasich.encly.presentation.navigation.NavRoutes import com.pasich.encly.presentation.navigation.RelockReturn @@ -144,6 +145,7 @@ class MainActivity : AppCompatActivity() { navController = navController, startDestination = destination.name, eraseEpoch = securityManager::eraseEpoch, + isDecoySession = securityManager::isDecoySession, shielded = shielded, locked = locked, strictKeyboard = strictKeyboard, @@ -309,6 +311,7 @@ fun App( navController: NavHostController, startDestination: String, eraseEpoch: () -> Long, + isDecoySession: () -> Boolean, modifier: Modifier = Modifier, shielded: Boolean = false, locked: Boolean = false, @@ -345,7 +348,10 @@ fun App( .fillMaxSize() .windowInsetsPadding(WindowInsets.statusBars), ) { - CompositionLocalProvider(LocalEraseEpoch provides eraseEpoch) { + CompositionLocalProvider( + LocalEraseEpoch provides eraseEpoch, + LocalDecoySession provides isDecoySession, + ) { AppNavHost( navController = navController, startDestination = startDestination, 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..f940b3eb 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,24 @@ package com.pasich.encly.core.security +import com.pasich.encly.core.security.DuressSlots.DECOY_KEY_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 +51,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 +79,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 +164,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 +193,26 @@ 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 PinChange.IN_USE 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,8 +223,10 @@ 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 + * 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 clears like the + * PIN. 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. * @@ -172,21 +236,34 @@ 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 { @@ -205,7 +282,7 @@ class AuthenticationManager @Inject constructor( return if (e.lost) PinUnlock.KeyLost else PinUnlock.Failed } try { - checkBothSlots(pin, parsed, keys, allowWipe) + checkSlots(pin, parsed, keys, purpose) } finally { keys.wipe() } @@ -245,6 +322,8 @@ class AuthenticationManager @Inject constructor( SensitiveDataCleaner.clear(dek) return WipePinChange.SAME_AS_PIN } + // Nor may it be the duress PIN, which opens the decoy vault first. + if (opensDecoySlot(keys.decoyKek, parsed.salt)) return WipePinChange.IN_USE writeWipeSlot(keys.wipeKek, parsed.salt) } finally { keys.wipe() @@ -267,6 +346,88 @@ 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). + */ + @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) 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 DuressPinChange.SAME_AS_PIN + } + 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 DuressPinChange.IN_USE + 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) + remove(DECOY_NOTICE_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 { + val decoyDek = openDecoyKeyOrNull(primaryDek) ?: return false + SensitiveDataCleaner.clear(decoyDek) + return true + } + + /** + * True after the PIN key had to be reset (see [configurePin]) while a decoy vault existed: + * its duress PIN stopped working. Setting it again opens the same decoy notes. + */ + fun duressPinTurnedOff(): Boolean = store.getBoolean(DECOY_NOTICE_KEY, false) + /** * 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 +443,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 +553,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, purpose: Purpose): 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 +574,31 @@ class AuthenticationManager @Inject constructor( } finally { SensitiveDataCleaner.clear(wipeSlot) } - return when { - dek != null -> { + val decoyDek = openDecoySlotOrNull(keys.decoyKek, parsed.salt) + val matched = match(dek, decoyDek, wipeMatch, 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, is PinUnlock.Decoy -> { store.edit { removePrefix(LOCKOUT_PREFIX) } - PinUnlock.Success(dek) + matched } - wipeMatch && allowWipe -> eraseVault(pin, parsed.salt, keys) - - 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,12 +627,19 @@ 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) + remove(DECOY_NOTICE_KEY) putInt(KEY_SLOT_KEY, keySlot.ordinal) putInt(WIPE_PENDING_KEY, WipeStage.DATABASE.ordinal) remove(WIPE_NOTICE_KEY) @@ -461,6 +657,8 @@ class AuthenticationManager @Inject constructor( SensitiveDataCleaner.clear(dek) SensitiveDataCleaner.clear(kek) SensitiveDataCleaner.clear(decoy) + SensitiveDataCleaner.clear(duressSlot) + SensitiveDataCleaner.clear(decoyKey) } } @@ -490,22 +688,73 @@ 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() + // Told only when there was a decoy vault to lose; the store can not know it without a DEK. + val tellDuress = decoyChange == WipeSlotChange.TURN_OFF_AND_TELL && hasDecoyVault(dek) + // A first PIN (no salt yet) starts a vault without any decoy key either. + val decoyKey = if (store.contains(DECOY_KEY_KEY)) null else randomSlot() 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) + if (tellDuress) putBoolean(DECOY_NOTICE_KEY, true) removePrefix(LOCKOUT_PREFIX) } } 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) + // 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) + remove(DECOY_NOTICE_KEY) + } + } 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) } } @@ -581,6 +830,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,93 +868,41 @@ 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 - return try { - byteArrayOf(SLOT_VERSION) + salt + iv + encrypted - } finally { - SensitiveDataCleaner.clear(encrypted) - } - } - - 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 + /** 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 } - 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 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) + cipher.iv + encrypted + openDecoy(slot, decoyKek, decoySlotAad(salt)) } finally { - SensitiveDataCleaner.clear(encrypted) + SensitiveDataCleaner.clear(slot) } } - /** 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) + /** The decoy DEK sealed under [primaryDek] in `decoy.key`, or null when there is none. */ + private fun openDecoyKeyOrNull(primaryDek: ByteArray): ByteArray? { + val sealed = store.getBytes(DECOY_KEY_KEY) ?: return null + 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 + openDecoy(sealed, key, DECOY_KEY_AAD) } finally { - SensitiveDataCleaner.clear(iv) - SensitiveDataCleaner.clear(ciphertext) + SensitiveDataCleaner.clear(sealed) + 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) + private fun sealDecoyKey(decoyDek: ByteArray, primaryDek: ByteArray): ByteArray { + val key = decoyKeyKek(primaryDek) + return try { + sealDecoy(decoyDek, key, DECOY_KEY_AAD) + } finally { + SensitiveDataCleaner.clear(key) } } @@ -714,17 +912,34 @@ class AuthenticationManager @Inject constructor( 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 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 +947,10 @@ 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_NOTICE_KEY = "pin.decoy.notice" + 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" @@ -746,22 +965,11 @@ class AuthenticationManager @Inject constructor( const val MAX_LOCKOUT_MS = 24 * 60 * 60_000L 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( 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..d96f83e3 --- /dev/null +++ b/app/src/main/java/com/pasich/encly/core/security/DuressSlots.kt @@ -0,0 +1,68 @@ +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) + 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..65cf128d 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 uses it to leave out 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,56 @@ 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; false outside a session of the real vault. */ + fun hasDuressPin(): Boolean { + val dek = primarySessionKeyCopy() ?: return false + return try { + authenticationManager.hasDecoyVault(dek) + } finally { + SensitiveDataCleaner.clear(dek) + } + } /** * Completes first-run setup only after a PIN slot exists and the bootstrap DEK opens SQLCipher. @@ -508,18 +615,36 @@ 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 } + /** Erases everything: both vaults, every key slot. Never from a decoy session. */ fun wipeAndReset() { + if (isDecoySession()) return secureDatabaseManager.wipe() clearSessionKey() biometricManager.disable() @@ -537,6 +662,9 @@ class SecurityManager @Inject constructor( isUserCreatedSeedKey = seedPhraseManager.hasRecoverySeed(), hasPinSlot = authenticationManager.hasPinSlot(), wipePinTurnedOff = authenticationManager.wipePinTurnedOff(), + isDecoySession = isDecoySession(), + hasDuressPin = hasDuressPin(), + duressPinTurnedOff = !isDecoySession() && authenticationManager.duressPinTurnedOff(), ) } @@ -550,4 +678,10 @@ data class AuthSettings( val isUserCreatedSeedKey: Boolean, val hasPinSlot: Boolean = authType == AuthType.PIN, val wipePinTurnedOff: Boolean = false, + /** The decoy vault is open: Settings leaves out everything of the real vault. */ + val isDecoySession: Boolean = false, + /** The real vault has a duress PIN (known only in its own session). */ + val hasDuressPin: Boolean = false, + /** The PIN key was reset, so the duress PIN stopped working; setting it again keeps the notes. */ + val duressPinTurnedOff: Boolean = false, ) 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 80d597fb..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 @@ -8,9 +8,9 @@ interface DatabaseProvider { /** 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 is now open. */ + /** + * 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 - - /** Legacy entry point for backup code; it must be bound to a lease before decoy is enabled. */ - fun getDatabase(): 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 f57e8d01..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,11 +15,20 @@ 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) : @@ -27,6 +36,7 @@ class SecureDatabaseManager @Inject constructor(@param:ApplicationContext privat 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 @@ -38,8 +48,8 @@ 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 @Synchronized override fun captureLease(): DatabaseLease? = lease @@ -49,10 +59,6 @@ class SecureDatabaseManager @Inject constructor(@param:ApplicationContext privat isUnlocked && this.lease === lease } ?: error("Database session expired") - @Synchronized - override fun getDatabase(): AppDatabase = database?.takeIf { isUnlocked } - ?: error("Database accessed before unlock — call unlockDatabase() first") - /** * Opens the vault with the raw 32-byte [dek]; the caller keeps (and wipes) [dek]. * @@ -60,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) } @@ -79,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. @@ -90,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 @@ -102,12 +113,14 @@ 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 @@ -118,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) } @@ -135,30 +149,35 @@ class SecureDatabaseManager @Inject constructor(@param:ApplicationContext privat 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/presentation/components/settings/SettingsBuilder.kt b/app/src/main/java/com/pasich/encly/presentation/components/settings/SettingsBuilder.kt index 2e7a2998..9fd95d5f 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 @@ -30,12 +30,14 @@ class SettingsBuilder { showTasks: Boolean, simpleEdit: Boolean, navigation: SettingsNavigation, - ): List = listOf( + backupAvailable: Boolean = true, + ): List = listOfNotNull( general(viewModel, navigation), notesAndTasks(viewModel, showTasks, simpleEdit), single(R.string.settings_privacy, R.string.security_title, R.string.security_subtitle, EnclyIcons.Shield) { navigation.onSecurity() }, + // Backups belong to the real vault; the decoy one lists none. single( R.string.settings_backup, R.string.backup_title, @@ -43,7 +45,7 @@ class SettingsBuilder { EnclyIcons.Download, ) { navigation.onBackup() - }, + }.takeIf { backupAvailable }, SettingsCategory( titleRes = R.string.about, items = listOf( diff --git a/app/src/main/java/com/pasich/encly/presentation/navigation/DecoySession.kt b/app/src/main/java/com/pasich/encly/presentation/navigation/DecoySession.kt new file mode 100644 index 00000000..d9dc978e --- /dev/null +++ b/app/src/main/java/com/pasich/encly/presentation/navigation/DecoySession.kt @@ -0,0 +1,36 @@ +package com.pasich.encly.presentation.navigation + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.navigation.NavHostController + +/** + * SecurityManager.isDecoySession, which MainActivity provides. It is a function, not a value: + * the vault can differ from one unlock to the next, so it is asked each time a screen composes + * and never kept. Without it every session is the real vault. + */ +val LocalDecoySession = staticCompositionLocalOf<() -> Boolean> { { false } } + +/** Whether the vault that is open now is the decoy one; screens ask this while composing. */ +@Composable +fun isDecoySession(): Boolean = LocalDecoySession.current() + +/** + * Screens that only make sense in the real vault (backup, PIN settings). In a decoy session they + * never compose: the user is sent to Settings instead, where those entries are not listed, so + * nothing on the way hints that another vault exists. + */ +@Composable +internal fun RealVaultOnly(navController: NavHostController, content: @Composable () -> Unit) { + if (isDecoySession()) { + LaunchedEffect(Unit) { + navController.navigate(NavRoutes.SettingsRoute.name) { + popUpTo(NavRoutes.SettingsRoute.name) { inclusive = true } + launchSingleTop = true + } + } + } else { + content() + } +} 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 21cab1f9..fe503473 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 @@ -26,6 +26,7 @@ import com.pasich.encly.presentation.screen.backup.BackupScreen import com.pasich.encly.presentation.screen.editnote.EditNoteScreen 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 @@ -105,7 +106,7 @@ fun AppNavHost(navController: NavHostController, startDestination: String = NavR } animationScreens(NavRoutes.BackupRoute.name) { - BackupScreen(navController) + RealVaultOnly(navController) { BackupScreen(navController) } } animationScreens(NavRoutes.OnboardingRoute.name) { @@ -125,11 +126,15 @@ fun AppNavHost(navController: NavHostController, startDestination: String = NavR LossRoute(navController, NavRoutes.LegacyVaultRoute, LossReason.OLDER_VERSION) } animationScreens(NavRoutes.PinCodeConfig.name) { - PinCodeConfigScreen(navController) + RealVaultOnly(navController) { PinCodeConfigScreen(navController) } } animationScreens(NavRoutes.WipePinRoute.name) { - WipePinScreen(navController) + RealVaultOnly(navController) { WipePinScreen(navController) } + } + + animationScreens(NavRoutes.DuressPinRoute.name) { + RealVaultOnly(navController) { DuressPinScreen(navController) } } animationScreens(NavRoutes.LockRoute.name) { 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..ac315e01 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,6 +16,7 @@ enum class NavRoutes { LegacyVaultRoute, PinCodeConfig, WipePinRoute, + DuressPinRoute, LockRoute, BackupRoute, AppearanceRoute, 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 885eb089..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 @@ -39,6 +39,7 @@ import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics 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 @@ -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 { 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..e367d94e --- /dev/null +++ b/app/src/main/java/com/pasich/encly/presentation/screen/settings/DuressPinScreen.kt @@ -0,0 +1,170 @@ +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, only in a session of the real vault (see the guard in + * AppNavHost). 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. + */ +@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. */ +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) 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 f6845c98..04c44ae2 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,7 +18,6 @@ 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 @@ -36,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 @@ -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,85 @@ 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), + if (securityState.isDecoySession) { + // Nothing of the real vault: no PIN, phrase, biometric or erase entries, and no notices. + DecoySecurityContent(options, actions) + } else { + RealSecurityContent(securityState, options, vaultBusy, actions) + } + } +} + +/** What Security shows in the decoy vault: only the two preferences that belong to the app. */ +@Composable +private fun DecoySecurityContent(options: SecurityOptions, actions: SecurityActions) { + SectionOverline( + text = stringResource(R.string.auth_title), + modifier = Modifier.padding(top = EnclyTheme.spacing.s), + ) + EnclyGroup { + AutoLockRow(current = options.autoLock, onSelect = actions.onAutoLock) + } + PrivacySection(strictKeyboard = options.strictKeyboard, onStrictKeyboard = actions.onStrictKeyboard) +} + +@Composable +private fun RealSecurityContent( + 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( @@ -256,97 +281,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 b0be1afc..a6a2f4a6 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 @@ -23,6 +23,7 @@ import com.pasich.encly.presentation.components.settings.SettingsNavigation 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.navigation.isDecoySession import com.pasich.encly.presentation.viewmodel.SettingsViewModel import com.pasich.encly.presentation.viewmodel.enclyViewModel import com.pasich.encly.ui.theme.EnclyTheme @@ -39,6 +40,7 @@ fun SettingsScreen( val languageDialogVisible by settingViewState.languageDialogVisible.collectAsState() + val decoy = isDecoySession() val settingsBuilder = remember { SettingsBuilder() } val settingsCategories = settingsBuilder.buildSettingsCategories( viewModel = settingViewState, @@ -51,6 +53,7 @@ fun SettingsScreen( onAbout = { navController?.navigate(NavRoutes.AboutRoute.name) }, onFaq = { navController?.navigate(NavRoutes.FaqRoute.name) }, ), + backupAvailable = !decoy, ) Scaffold( 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 2e310f78..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,243 +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.res.stringResource import androidx.fragment.app.FragmentActivity 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 = 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 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) } +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) } } @@ -304,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/viewmodel/SecuritySettingsViewModel.kt b/app/src/main/java/com/pasich/encly/presentation/viewmodel/SecuritySettingsViewModel.kt index 6fb83537..b3dc7454 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,9 @@ class SecuritySettingsViewModel @Inject constructor( biometricStatus = securityManager.biometricStatus(), hasPinSlot = authSettings.hasPinSlot, wipePinTurnedOff = authSettings.wipePinTurnedOff, + isDecoySession = authSettings.isDecoySession, + hasDuressPin = authSettings.hasDuressPin, + duressPinTurnedOff = authSettings.duressPinTurnedOff, loaded = true, ) } @@ -95,21 +100,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 +142,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 +216,12 @@ 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 decoy vault is open: the page shows nothing of the real vault. */ + val isDecoySession: 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/res/values-de/strings_security.xml b/app/src/main/res/values-de/strings_security.xml index aaa86b76..95063cb2 100644 --- a/app/src/main/res/values-de/strings_security.xml +++ b/app/src/main/res/values-de/strings_security.xml @@ -93,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_security.xml b/app/src/main/res/values-es/strings_security.xml index b3c49bcc..fb3a18b8 100644 --- a/app/src/main/res/values-es/strings_security.xml +++ b/app/src/main/res/values-es/strings_security.xml @@ -98,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_security.xml b/app/src/main/res/values-fr/strings_security.xml index 56f93e67..e603440f 100644 --- a/app/src/main/res/values-fr/strings_security.xml +++ b/app/src/main/res/values-fr/strings_security.xml @@ -98,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_security.xml b/app/src/main/res/values-it/strings_security.xml index 76420438..ba9a4ec1 100644 --- a/app/src/main/res/values-it/strings_security.xml +++ b/app/src/main/res/values-it/strings_security.xml @@ -98,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_security.xml b/app/src/main/res/values-nl/strings_security.xml index eb1e6d9e..2af80eb7 100644 --- a/app/src/main/res/values-nl/strings_security.xml +++ b/app/src/main/res/values-nl/strings_security.xml @@ -93,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_security.xml b/app/src/main/res/values-pl/strings_security.xml index 351c1f86..0b2711ba 100644 --- a/app/src/main/res/values-pl/strings_security.xml +++ b/app/src/main/res/values-pl/strings_security.xml @@ -103,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_security.xml b/app/src/main/res/values-pt/strings_security.xml index 36ae7677..e97abf45 100644 --- a/app/src/main/res/values-pt/strings_security.xml +++ b/app/src/main/res/values-pt/strings_security.xml @@ -98,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_security.xml b/app/src/main/res/values-uk/strings_security.xml index 5fa80cb6..01f893d4 100644 --- a/app/src/main/res/values-uk/strings_security.xml +++ b/app/src/main/res/values-uk/strings_security.xml @@ -103,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_security.xml b/app/src/main/res/values/strings_security.xml index 14acf89f..1da0ef2f 100644 --- a/app/src/main/res/values/strings_security.xml +++ b/app/src/main/res/values/strings_security.xml @@ -93,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/security/DuressPinTest.kt b/app/src/test/java/com/pasich/encly/core/security/DuressPinTest.kt new file mode 100644 index 00000000..296d4357 --- /dev/null +++ b/app/src/test/java/com/pasich/encly/core/security/DuressPinTest.kt @@ -0,0 +1,466 @@ +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 val dek = ByteArray(DEK_LENGTH) { 0x3C } + + @Before + fun setUp() { + store = tempVaultStore() + factor = FakePinFactor() + auth = AuthenticationManager(store, factor, FakeLockoutClock()) + 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)) + } + + @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()) + } + + @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()) + 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()) + 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()) + } + + @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() + 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()) + assertTrue(vault.security.getSettingsAuth().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" + } +} 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..6d519642 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()) diff --git a/app/src/test/java/com/pasich/encly/data/repository/DaoRepositoriesTest.kt b/app/src/test/java/com/pasich/encly/data/repository/DaoRepositoriesTest.kt index c0840e85..b34e5a36 100644 --- a/app/src/test/java/com/pasich/encly/data/repository/DaoRepositoriesTest.kt +++ b/app/src/test/java/com/pasich/encly/data/repository/DaoRepositoriesTest.kt @@ -38,8 +38,6 @@ class DaoRepositoriesTest { if (lease !== this@DaoRepositoriesTest.lease) error("Database session expired") return database } - - override fun getDatabase(): AppDatabase = database } private val tasks = TasksRepositoryImpl(provider) diff --git a/app/src/test/java/com/pasich/encly/data/repository/RepositoryImplTest.kt b/app/src/test/java/com/pasich/encly/data/repository/RepositoryImplTest.kt index 6da24b04..538e41d6 100644 --- a/app/src/test/java/com/pasich/encly/data/repository/RepositoryImplTest.kt +++ b/app/src/test/java/com/pasich/encly/data/repository/RepositoryImplTest.kt @@ -41,11 +41,6 @@ class RepositoryImplTest { if (locked || lease !== this@RepositoryImplTest.lease) error("vault locked") return database } - - override fun getDatabase(): AppDatabase { - if (locked) error("vault locked") - return database - } } @Test diff --git a/app/src/test/java/com/pasich/encly/presentation/viewmodel/SecuritySettingsViewModelTest.kt b/app/src/test/java/com/pasich/encly/presentation/viewmodel/SecuritySettingsViewModelTest.kt index 88a9bcfd..ded7acf2 100644 --- a/app/src/test/java/com/pasich/encly/presentation/viewmodel/SecuritySettingsViewModelTest.kt +++ b/app/src/test/java/com/pasich/encly/presentation/viewmodel/SecuritySettingsViewModelTest.kt @@ -7,7 +7,9 @@ import com.pasich.encly.core.security.AuthSettings import com.pasich.encly.core.security.AuthType import com.pasich.encly.core.security.AutoLock 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.WipePinChange import com.pasich.encly.testutil.InMemorySharedPreferences @@ -114,8 +116,8 @@ class SecuritySettingsViewModelTest { @Test fun aNewPinIsStoredOrItsFailureShown() = runTest { viewModel.uiState.first { it.loaded } - `when`(security.configurePin("333333".toCharArray())).thenReturn(true) - `when`(security.configurePin("444444".toCharArray())).thenReturn(false) + `when`(security.changePin("333333".toCharArray())).thenReturn(PinChange.SET) + `when`(security.changePin("444444".toCharArray())).thenReturn(PinChange.FAILED) assertTrue(activate("333333")) assertEquals(AuthType.PIN, viewModel.uiState.value.authType) @@ -125,11 +127,48 @@ class SecuritySettingsViewModelTest { assertEquals(UiText.of(R.string.pin_update_failed), viewModel.uiState.value.error) } + @Test + fun aPinEqualToTheDuressPinIsRefusedWithoutAnError() = runTest { + viewModel.uiState.first { it.loaded } + `when`(security.changePin("555555".toCharArray())).thenReturn(PinChange.IN_USE) + + assertFalse(activate("555555")) + assertNull(viewModel.uiState.value.error) + } + + @Test + fun aDuressPinIsHandedOnAndRemembered() = runTest { + viewModel.uiState.first { it.loaded } + `when`(security.configureDuressPin(anyCharArray())).thenReturn(DuressPinChange.SET_NEW_VAULT) + val result = CompletableDeferred() + + viewModel.setDuressPin("864200".toCharArray()) { result.complete(it) } + + assertEquals(DuressPinChange.SET_NEW_VAULT, result.await()) + assertTrue(viewModel.uiState.value.hasDuressPin) + } + + @Test + fun turningTheDuressPinOffForgetsIt() = runTest { + `when`(security.getSettingsAuth()).thenReturn( + AuthSettings(AuthType.PIN, isBiometricEnabled = false, isUserCreatedSeedKey = false, hasDuressPin = true), + ) + viewModel.refresh() + viewModel.uiState.first { it.hasDuressPin } + `when`(security.removeDuressPin()).thenReturn(true) + val result = CompletableDeferred() + + viewModel.removeDuressPin { result.complete(it) } + + assertTrue(result.await()) + assertFalse(viewModel.uiState.value.hasDuressPin) + } + @Test fun theCheckedAndTheNewPinAreWipedAfterUse() = runTest { viewModel.uiState.first { it.loaded } `when`(security.verifyPin(anyCharArray())).thenReturn(true) - `when`(security.configurePin(anyCharArray())).thenReturn(false) + `when`(security.changePin(anyCharArray())).thenReturn(PinChange.FAILED) val current = "111111".toCharArray() assertTrue(checkCurrent(current)) @@ -322,9 +361,9 @@ class SecuritySettingsViewModelTest { private suspend fun activate(pin: String): Boolean = activate(pin.toCharArray()) private suspend fun activate(pin: CharArray): Boolean { - val result = CompletableDeferred() + val result = CompletableDeferred() viewModel.activationPinAuth(pin) { result.complete(it) } - return result.await() + return result.await() == PinChange.SET } private companion object { diff --git a/app/src/test/java/com/pasich/encly/ui/screens/SecurityScreensTest.kt b/app/src/test/java/com/pasich/encly/ui/screens/SecurityScreensTest.kt index 4deb19b5..2e68d70e 100644 --- a/app/src/test/java/com/pasich/encly/ui/screens/SecurityScreensTest.kt +++ b/app/src/test/java/com/pasich/encly/ui/screens/SecurityScreensTest.kt @@ -1,5 +1,6 @@ package com.pasich.encly.ui.screens +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.assertIsEnabled import androidx.compose.ui.test.assertIsNotEnabled @@ -11,10 +12,17 @@ import androidx.compose.ui.test.performClick import cash.z.ecc.android.bip39.Mnemonics.MnemonicCode import cash.z.ecc.android.bip39.Mnemonics.WordCount import com.pasich.encly.R +import com.pasich.encly.core.security.AuthSettings +import com.pasich.encly.core.security.AuthType import com.pasich.encly.core.security.BiometricStatus +import com.pasich.encly.core.security.DuressPinChange +import com.pasich.encly.core.security.PinChange import com.pasich.encly.core.security.WipePinChange +import com.pasich.encly.presentation.navigation.LocalDecoySession import com.pasich.encly.presentation.navigation.NavRoutes +import com.pasich.encly.presentation.navigation.RealVaultOnly import com.pasich.encly.presentation.screen.PinCodeConfigScreen +import com.pasich.encly.presentation.screen.settings.DuressPinScreen import com.pasich.encly.presentation.screen.settings.SecuritySettingsScreen import com.pasich.encly.presentation.screen.settings.WipePinScreen import com.pasich.encly.presentation.viewmodel.BackupStep @@ -140,11 +148,11 @@ class SecurityScreensTest : ComposeScreenTest() { fun aNewPinIsConfirmedAndSaved() { val saved = mutableListOf() val passed = mutableListOf() - `when`(app.security.configurePin(anyCharArray())).thenAnswer { + `when`(app.security.changePin(anyCharArray())).thenAnswer { val pin = it.getArgument(0) passed += pin saved += String(pin) - true + PinChange.SET } showPinChange() @@ -300,4 +308,158 @@ class SecurityScreensTest : ComposeScreenTest() { waitForText(str(R.string.wipe_pin_removed)) verify(app.security, never()).configureWipePin(anyCharArray()) } + + // --- duress PIN ----------------------------------------------------------------------- + + private fun showDuressPin(isSet: Boolean = false) { + `when`(app.security.verifyPin(anyCharArray())).thenReturn(true) + `when`(app.security.getSettingsAuth()).thenReturn(AuthSettings(AuthType.PIN, false, true, hasDuressPin = isSet)) + setNavScreen( + viewModels(app.securitySettings()), + route = NavRoutes.DuressPinRoute.name, + start = NavRoutes.SecuritySettingsRoute.name, + ) { nav -> DuressPinScreen(nav) } + navigateTo(NavRoutes.DuressPinRoute.name) + } + + private fun enterDuressPin(result: DuressPinChange) { + `when`(app.security.configureDuressPin(anyCharArray())).thenReturn(result) + typePin("123456") + waitForText(str(R.string.duress_pin_intro)) + scrollToText(str(R.string.duress_pin_set)) + rule.onNodeWithText(str(R.string.duress_pin_set)).performClick() + waitForText(str(R.string.duress_pin_new_title)) + typePin("864200") + waitForText(str(R.string.duress_pin_confirm_title)) + typePin("864200") + } + + @Test + fun theDuressPinRowOpensTheDuressPinPage() { + showSecurity(recoveryPhrase = true) + + scrollToText(str(R.string.duress_pin_row_desc)) + rule.onNodeWithText(str(R.string.duress_pin_row_desc)).performClick() + + waitFor { currentRoute() == NavRoutes.DuressPinRoute.name } + } + + @Test + fun theDuressPinPageAsksForThePinFirst() { + showDuressPin() + `when`(app.security.verifyPin(anyCharArray())).thenReturn(false) + + rule.onNodeWithText(str(R.string.duress_pin_current_subtitle)).assertIsDisplayed() + typePin("000000") + + waitForText(str(R.string.pin_current_wrong)) + assertEquals(0, countText(str(R.string.duress_pin_set))) + } + + @Test + fun aNewDuressPinIsSaved() { + showDuressPin() + + enterDuressPin(DuressPinChange.SET_NEW_VAULT) + + waitForText(str(R.string.duress_pin_saved)) + } + + @Test + fun aChangedDuressPinSaysTheDecoyNotesStay() { + showDuressPin() + + enterDuressPin(DuressPinChange.SET) + + waitForText(str(R.string.duress_pin_saved_kept)) + } + + @Test + fun aDuressPinEqualToThePinIsRefused() { + showDuressPin() + + enterDuressPin(DuressPinChange.SAME_AS_PIN) + + waitForText(str(R.string.duress_pin_same_as_pin)) + rule.onNodeWithText(str(R.string.duress_pin_new_title)).assertIsDisplayed() + } + + @Test + fun aDuressPinInUseIsRefusedGenerically() { + showDuressPin() + + enterDuressPin(DuressPinChange.IN_USE) + + waitForText(str(R.string.pin_in_use)) + } + + @Test + fun turningTheDuressPinOffAsksForConfirmation() { + showDuressPin(isSet = true) + `when`(app.security.removeDuressPin()).thenReturn(true) + + typePin("123456") + waitForText(str(R.string.duress_pin_intro)) + scrollToText(str(R.string.duress_pin_remove)) + rule.onNodeWithText(str(R.string.duress_pin_remove)).performClick() + waitForText(str(R.string.duress_pin_remove_confirm_body)) + verify(app.security, never()).removeDuressPin() + rule.onNodeWithText(str(R.string.duress_pin_remove_confirm)).performClick() + + waitForText(str(R.string.duress_pin_removed)) + } + + @Test + fun aPinChangeToTheDuressPinIsRefused() { + `when`(app.security.changePin(anyCharArray())).thenReturn(PinChange.IN_USE) + showPinChange() + + typePin("123456") + waitForText(str(R.string.pin_create_title)) + typePin("654321") + waitForText(str(R.string.pin_change_confirm_title)) + typePin("654321") + + waitForText(str(R.string.pin_in_use)) + } + + // --- decoy session -------------------------------------------------------------------- + + @Test + fun theDecoySecurityPageListsOnlyAutoLockAndKeyboardPrivacy() { + app.inDecoySession() + vault = app.backup() + setNavScreen(viewModels(app.securitySettings(), vault), route = NavRoutes.SecuritySettingsRoute.name) { nav -> + SecuritySettingsScreen(nav) + } + + waitForText(str(R.string.security_keyboard_strict_title)) + listOf( + R.string.auth_method_pin_title, + R.string.security_biometric_title, + R.string.wipe_pin_title, + R.string.duress_pin_title, + R.string.backup_needs_phrase_title, + R.string.security_erase_title, + R.string.security_encryption_active, + ).forEach { assertEquals(str(it), 0, countText(str(it))) } + } + + @Test + fun theRealVaultOnlyScreensSendTheDecoySessionToSettings() { + app.inDecoySession() + setNavScreen( + viewModels(app.securitySettings()), + route = NavRoutes.WipePinRoute.name, + start = NavRoutes.SecuritySettingsRoute.name, + ) { nav -> + CompositionLocalProvider(LocalDecoySession provides { true }) { + RealVaultOnly(nav) { WipePinScreen(nav) } + } + } + navigateTo(NavRoutes.WipePinRoute.name) + + waitFor { currentRoute() == NavRoutes.SettingsRoute.name } + assertEquals(0, countText(str(R.string.wipe_pin_current_subtitle))) + } } diff --git a/app/src/test/java/com/pasich/encly/ui/screens/SettingsScreensTest.kt b/app/src/test/java/com/pasich/encly/ui/screens/SettingsScreensTest.kt index 3cf1abaf..ae795671 100644 --- a/app/src/test/java/com/pasich/encly/ui/screens/SettingsScreensTest.kt +++ b/app/src/test/java/com/pasich/encly/ui/screens/SettingsScreensTest.kt @@ -1,6 +1,7 @@ package com.pasich.encly.ui.screens import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.SideEffect import androidx.compose.ui.graphics.Color import androidx.compose.ui.semantics.SemanticsProperties @@ -14,6 +15,7 @@ import com.pasich.encly.core.locale.AppLocales import com.pasich.encly.domain.model.FontStyleType import com.pasich.encly.domain.model.ThemePalette import com.pasich.encly.domain.model.ThemeType +import com.pasich.encly.presentation.navigation.LocalDecoySession import com.pasich.encly.presentation.navigation.NavRoutes import com.pasich.encly.presentation.screen.settings.AppearanceScreen import com.pasich.encly.presentation.screen.settings.SettingsScreen @@ -64,6 +66,18 @@ class SettingsScreensTest : ComposeScreenTest() { assertBodyNotEmpty(minTexts = 6) } + @Test + fun theDecoyVaultHasNoBackupEntry() { + setNavScreen(viewModels(app.settingsVm()), route = NavRoutes.SettingsRoute.name) { nav -> + CompositionLocalProvider(LocalDecoySession provides { true }) { SettingsScreen(nav) } + } + + scrollToText(str(R.string.settings_privacy), ignoreCase = true) + scrollToText(str(R.string.settings_appearance)) + assertEquals(0, countText(str(R.string.settings_backup), ignoreCase = true)) + assertEquals(0, countText(str(R.string.backup_title))) + } + @Test fun theAppearanceRowOpensAppearance() { showSettings() diff --git a/app/src/test/java/com/pasich/encly/ui/screens/TestApp.kt b/app/src/test/java/com/pasich/encly/ui/screens/TestApp.kt index b3b59753..fc05af20 100644 --- a/app/src/test/java/com/pasich/encly/ui/screens/TestApp.kt +++ b/app/src/test/java/com/pasich/encly/ui/screens/TestApp.kt @@ -12,6 +12,7 @@ import com.pasich.encly.core.security.AuthType import com.pasich.encly.core.security.AutoLock import com.pasich.encly.core.security.BiometricStatus 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.SessionLockManager import com.pasich.encly.data.backup.BackupDocuments @@ -101,12 +102,22 @@ internal class TestApp(context: Context) { `when`(security.generateMnemonicCode()).thenAnswer { phrase.toCharArray() } `when`(security.initializeNewVault(anyChars())).thenReturn(true) `when`(security.configurePin(anyCharArray())).thenReturn(true) + `when`(security.changePin(anyCharArray())).thenReturn(PinChange.SET) `when`(security.verifyPin(anyCharArray())).thenReturn(true) `when`(security.isValidRecoveryPhrase(anyChars() ?: CharArray(0))).thenAnswer { runCatching { MnemonicCode(it.getArgument(0).copyOf()).validate() }.isSuccess } } + /** The decoy vault is open: Security lists nothing of the real vault. */ + fun inDecoySession() = apply { + `when`(security.getSettingsAuth()).thenReturn( + AuthSettings(AuthType.PIN, false, false, isDecoySession = true), + ) + `when`(security.biometricStatus()).thenReturn(BiometricStatus.UNAVAILABLE) + `when`(security.isDecoySession()).thenReturn(true) + } + /** The vault's stored security settings, as the Security page reads them. */ fun withAuth(recoveryPhrase: Boolean, biometric: BiometricStatus = BiometricStatus.UNAVAILABLE) = apply { `when`(security.getSettingsAuth()).thenReturn(AuthSettings(AuthType.PIN, false, recoveryPhrase)) diff --git a/config/detekt/detekt.yml b/config/detekt/detekt.yml index 01c8a686..3eba418c 100644 --- a/config/detekt/detekt.yml +++ b/config/detekt/detekt.yml @@ -56,6 +56,8 @@ Compose: - LocalSpacing # The vault's erase epoch, provided once by App for the note editor (presentation/navigation/RelockReturn.kt). - LocalEraseEpoch + # Whether the open vault is the decoy one, provided once by App (presentation/navigation/DecoySession.kt). + - LocalDecoySession CompositionLocalNaming: active: true ContentEmitterReturningValues: diff --git a/docs/architecture.md b/docs/architecture.md index 7b3fd80f..4d3068a8 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -37,9 +37,14 @@ A fresh vault generates a random 256-bit DEK. SQLCipher uses the DEK; the mandat optional auth-bound biometric key and optional recovery seed each protect a separate AES-GCM envelope of that same DEK. No UI layer derives or stores the database key directly. -`SecureDatabaseManager` owns the active Room instance. Repositories resolve DAOs through -`DatabaseLocalDataSource` at operation time, rather than retaining an old DAO after the -database is closed on re-lock. +`SecureDatabaseManager` owns the active Room instance: the real vault's `database.db` or, after +the duress PIN, the decoy vault's `vault_b.db`, never both at once. Each opening issues a +`DatabaseLease`; repositories and the backup data store capture the lease when they are created +and resolve DAOs through it at operation time, so after a re-lock (or an unlock of the other +vault) they fail instead of reading or writing a database they were not created for. +`SecurityManager` records which vault the session opened and refuses every operation on the +real vault's key slots (PIN, wipe and duress PINs, biometric, recovery, backup key) in a decoy +session; Settings leaves those screens out. ## Notes and tasks @@ -57,7 +62,8 @@ returning never shows plaintext. An unlock that completes after the app left the closed again at once. Open task and tag editors save on pause, like the note editor, because the re-lock discards them. The note that was open when the app re-locked is recorded (`RelockReturn`), with the erase epoch of the vault it was opened in, and opened again after the unlock, with fresh -ViewModels, only while that epoch is current (a wipe-PIN erase or a new process changes it). A +ViewModels, only while that epoch is current (a wipe-PIN erase, an unlock of the other vault or a new +process changes it). A screen that was in the background while the vault was unlocked on another lock screen (the My Notes hand-off) is replaced by the lock screen or Home when it comes back (`SessionResume`). Pages move on Material's shared X axis; From c5819bea071f01baac624956966be882bee765cf Mon Sep 17 00:00:00 2001 From: pasichdev Date: Wed, 14 Oct 2026 23:58:00 +0300 Subject: [PATCH 08/18] feat(import): add an offline Import notes screen for other apps Imports exports of Google Keep (Takeout), Standard Notes (decrypted backup), Joplin (JEX, RAW), Notesnook (Markdown, TXT) and plain Markdown/TXT folders such as an Obsidian vault. Files are read from the system picker into memory only, with bounded ZIP and TAR readers, detected by content, reviewed note by note before saving, and merged in one transaction into the open vault. Parsers are tested against real exports of each app. Refs: #62 --- CHANGELOG.md | 3 + CONTRIBUTING.md | 8 +- PRIVACY.md | 12 + SECURITY.md | 34 ++ .../encly/data/importer/DocumentTree.kt | 50 +++ .../encly/data/importer/FormatDetector.kt | 170 +++++++++ .../pasich/encly/data/importer/Frontmatter.kt | 96 +++++ .../encly/data/importer/ImportBlocks.kt | 31 ++ .../encly/data/importer/ImportMapper.kt | 238 ++++++++++++ .../encly/data/importer/ImportReader.kt | 217 +++++++++++ .../encly/data/importer/ImportReview.kt | 93 +++++ .../pasich/encly/data/importer/ImportText.kt | 103 ++++++ .../pasich/encly/data/importer/ImportTime.kt | 115 ++++++ .../pasich/encly/data/importer/ImportTypes.kt | 167 +++++++++ .../encly/data/importer/JoplinParser.kt | 98 +++++ .../pasich/encly/data/importer/KeepParser.kt | 87 +++++ .../encly/data/importer/LexicalBlocks.kt | 93 +++++ .../data/importer/MarkdownBlockMapper.kt | 180 ++++++++++ .../data/importer/MarkdownNotesParser.kt | 95 +++++ .../encly/data/importer/SafImportFiles.kt | 72 ++++ .../data/importer/StandardNotesParser.kt | 132 +++++++ .../pasich/encly/data/importer/TarReader.kt | 207 +++++++++++ .../encly/data/importer/ZipStreamReader.kt | 271 ++++++++++++++ .../components/settings/SettingsBuilder.kt | 10 + .../encly/presentation/navigation/NavHost.kt | 5 + .../presentation/navigation/NavRoutes.kt | 1 + .../screen/importnotes/ImportNotesActions.kt | 17 + .../screen/importnotes/ImportNotesScreen.kt | 126 +++++++ .../screen/importnotes/ImportPages.kt | 142 ++++++++ .../screen/importnotes/ImportReviewScreen.kt | 209 +++++++++++ .../screen/settings/SettingsScreen.kt | 1 + .../viewmodel/ImportNotesViewModel.kt | 339 ++++++++++++++++++ app/src/main/res/values-de/strings_import.xml | 80 +++++ app/src/main/res/values-es/strings_import.xml | 80 +++++ app/src/main/res/values-fr/strings_import.xml | 80 +++++ app/src/main/res/values-it/strings_import.xml | 80 +++++ app/src/main/res/values-nl/strings_import.xml | 80 +++++ app/src/main/res/values-pl/strings_import.xml | 80 +++++ app/src/main/res/values-pt/strings_import.xml | 80 +++++ app/src/main/res/values-uk/strings_import.xml | 80 +++++ app/src/main/res/values/strings_import.xml | 82 +++++ .../encly/data/importer/ExpectedNote.kt | 35 ++ .../encly/data/importer/FormatDetectorTest.kt | 126 +++++++ .../encly/data/importer/FrontmatterTest.kt | 63 ++++ .../encly/data/importer/ImportFixtures.kt | 157 ++++++++ .../encly/data/importer/ImportMapperTest.kt | 178 +++++++++ .../encly/data/importer/ImportReaderTest.kt | 217 +++++++++++ .../encly/data/importer/KeepParserTest.kt | 125 +++++++ .../data/importer/MarkdownBlockMapperTest.kt | 65 ++++ .../NotesnookAndMarkdownFixturesTest.kt | 202 +++++++++++ .../encly/data/importer/RealExportsTest.kt | 271 ++++++++++++++ .../encly/data/importer/TextParsersTest.kt | 309 ++++++++++++++++ .../data/importer/ZipStreamReaderTest.kt | 114 ++++++ .../encly/i18n/TranslationCompletenessTest.kt | 3 +- .../viewmodel/ImportNotesViewModelTest.kt | 334 +++++++++++++++++ .../encly/ui/screens/ImportNotesScreenTest.kt | 242 +++++++++++++ .../import-fixtures/joplin/README.md | 7 + .../joplin/md/Personal/Groceries.md | 3 + ...0\264\320\265\321\227 \360\237\222\241.md" | 3 + .../joplin/md/Work/Call the bank.md | 0 .../joplin/md/Work/Meeting notes.md | 15 + .../joplin/md/Work/Renew passport.md | 0 .../md_frontmatter/Personal/Groceries.md | 11 + ...0\264\320\265\321\227 \360\237\222\241.md" | 9 + .../md_frontmatter/Work/Call the bank.md | 7 + .../md_frontmatter/Work/Meeting notes.md | 24 ++ .../md_frontmatter/Work/Renew passport.md | 7 + .../import-fixtures/joplin/notes.jex | Bin 0 -> 16896 bytes .../raw/05e7fc905c484b89bd8d6aae13073c8f.md | 13 + .../raw/0b9a0c4713e54184a552af48e6b13460.md | 11 + .../raw/3d060bc9f9484e49b05a9f006cf362ab.md | 11 + .../raw/6ca5bdfec28c489c9d18a75584c72238.md | 17 + .../raw/70e99e28284f447fb84681d796a98d2f.md | 37 ++ .../raw/a0361945271f4eb5b2400c5e5ce4545a.md | 37 ++ .../raw/a3afd784a6344c2f90026d22a05adb76.md | 49 +++ .../raw/b3eaf8f8050a42178bb80c2e53e8fa34.md | 33 ++ .../raw/ba98e539389140aa90f4cd70fb226964.md | 11 + .../raw/c82ee80c86ff479c98f35a4e341e2164.md | 13 + .../raw/eaf85d5d4ff94f6c9b92eccf8ba453bf.md | 33 ++ .../raw/fa94f84f272643cfa3a74deb510ff642.md | 13 + .../raw/fe3af29373694b86b45af6ae9b2c6516.md | 17 + .../resources/import-fixtures/keep/README.md | 15 + .../import-fixtures/keep/takeout-keep.zip | Bin 0 -> 23922 bytes .../import-fixtures/markdown/README.md | 6 + .../obsidian-vault/.obsidian/app.json | 3 + .../obsidian-vault/.obsidian/workspace.json | 1 + .../obsidian-vault/Attachments/image.png | 2 + .../markdown/obsidian-vault/Groceries.md | 8 + .../markdown/obsidian-vault/Markor note.txt | 4 + .../obsidian-vault/Work/Meeting notes.md | 22 ++ ...0\264\320\265\321\227 \360\237\222\241.md" | 3 + .../import-fixtures/notesnook/README.md | 14 + .../notesnook/backup.nnbackupz | Bin 0 -> 3421 bytes .../notesnook/notes-md-frontmatter.zip | Bin 0 -> 2056 bytes .../import-fixtures/notesnook/notes-md.zip | Bin 0 -> 1719 bytes .../import-fixtures/notesnook/notes-txt.zip | Bin 0 -> 1711 bytes .../import-fixtures/standard-notes/README.md | 13 + .../Standard Notes Backup - decrypted.zip | Bin 0 -> 4891 bytes docs/architecture.md | 20 ++ 99 files changed, 7072 insertions(+), 5 deletions(-) create mode 100644 app/src/main/java/com/pasich/encly/data/importer/DocumentTree.kt create mode 100644 app/src/main/java/com/pasich/encly/data/importer/FormatDetector.kt create mode 100644 app/src/main/java/com/pasich/encly/data/importer/Frontmatter.kt create mode 100644 app/src/main/java/com/pasich/encly/data/importer/ImportBlocks.kt create mode 100644 app/src/main/java/com/pasich/encly/data/importer/ImportMapper.kt create mode 100644 app/src/main/java/com/pasich/encly/data/importer/ImportReader.kt create mode 100644 app/src/main/java/com/pasich/encly/data/importer/ImportReview.kt create mode 100644 app/src/main/java/com/pasich/encly/data/importer/ImportText.kt create mode 100644 app/src/main/java/com/pasich/encly/data/importer/ImportTime.kt create mode 100644 app/src/main/java/com/pasich/encly/data/importer/ImportTypes.kt create mode 100644 app/src/main/java/com/pasich/encly/data/importer/JoplinParser.kt create mode 100644 app/src/main/java/com/pasich/encly/data/importer/KeepParser.kt create mode 100644 app/src/main/java/com/pasich/encly/data/importer/LexicalBlocks.kt create mode 100644 app/src/main/java/com/pasich/encly/data/importer/MarkdownBlockMapper.kt create mode 100644 app/src/main/java/com/pasich/encly/data/importer/MarkdownNotesParser.kt create mode 100644 app/src/main/java/com/pasich/encly/data/importer/SafImportFiles.kt create mode 100644 app/src/main/java/com/pasich/encly/data/importer/StandardNotesParser.kt create mode 100644 app/src/main/java/com/pasich/encly/data/importer/TarReader.kt create mode 100644 app/src/main/java/com/pasich/encly/data/importer/ZipStreamReader.kt create mode 100644 app/src/main/java/com/pasich/encly/presentation/screen/importnotes/ImportNotesActions.kt create mode 100644 app/src/main/java/com/pasich/encly/presentation/screen/importnotes/ImportNotesScreen.kt create mode 100644 app/src/main/java/com/pasich/encly/presentation/screen/importnotes/ImportPages.kt create mode 100644 app/src/main/java/com/pasich/encly/presentation/screen/importnotes/ImportReviewScreen.kt create mode 100644 app/src/main/java/com/pasich/encly/presentation/viewmodel/ImportNotesViewModel.kt create mode 100644 app/src/main/res/values-de/strings_import.xml create mode 100644 app/src/main/res/values-es/strings_import.xml create mode 100644 app/src/main/res/values-fr/strings_import.xml create mode 100644 app/src/main/res/values-it/strings_import.xml create mode 100644 app/src/main/res/values-nl/strings_import.xml create mode 100644 app/src/main/res/values-pl/strings_import.xml create mode 100644 app/src/main/res/values-pt/strings_import.xml create mode 100644 app/src/main/res/values-uk/strings_import.xml create mode 100644 app/src/main/res/values/strings_import.xml create mode 100644 app/src/test/java/com/pasich/encly/data/importer/ExpectedNote.kt create mode 100644 app/src/test/java/com/pasich/encly/data/importer/FormatDetectorTest.kt create mode 100644 app/src/test/java/com/pasich/encly/data/importer/FrontmatterTest.kt create mode 100644 app/src/test/java/com/pasich/encly/data/importer/ImportFixtures.kt create mode 100644 app/src/test/java/com/pasich/encly/data/importer/ImportMapperTest.kt create mode 100644 app/src/test/java/com/pasich/encly/data/importer/ImportReaderTest.kt create mode 100644 app/src/test/java/com/pasich/encly/data/importer/KeepParserTest.kt create mode 100644 app/src/test/java/com/pasich/encly/data/importer/MarkdownBlockMapperTest.kt create mode 100644 app/src/test/java/com/pasich/encly/data/importer/NotesnookAndMarkdownFixturesTest.kt create mode 100644 app/src/test/java/com/pasich/encly/data/importer/RealExportsTest.kt create mode 100644 app/src/test/java/com/pasich/encly/data/importer/TextParsersTest.kt create mode 100644 app/src/test/java/com/pasich/encly/data/importer/ZipStreamReaderTest.kt create mode 100644 app/src/test/java/com/pasich/encly/presentation/viewmodel/ImportNotesViewModelTest.kt create mode 100644 app/src/test/java/com/pasich/encly/ui/screens/ImportNotesScreenTest.kt create mode 100644 app/src/test/resources/import-fixtures/joplin/README.md create mode 100644 app/src/test/resources/import-fixtures/joplin/md/Personal/Groceries.md create mode 100644 "app/src/test/resources/import-fixtures/joplin/md/Personal/\320\206\320\264\320\265\321\227 \360\237\222\241.md" create mode 100644 app/src/test/resources/import-fixtures/joplin/md/Work/Call the bank.md create mode 100644 app/src/test/resources/import-fixtures/joplin/md/Work/Meeting notes.md create mode 100644 app/src/test/resources/import-fixtures/joplin/md/Work/Renew passport.md create mode 100644 app/src/test/resources/import-fixtures/joplin/md_frontmatter/Personal/Groceries.md create mode 100644 "app/src/test/resources/import-fixtures/joplin/md_frontmatter/Personal/\320\206\320\264\320\265\321\227 \360\237\222\241.md" create mode 100644 app/src/test/resources/import-fixtures/joplin/md_frontmatter/Work/Call the bank.md create mode 100644 app/src/test/resources/import-fixtures/joplin/md_frontmatter/Work/Meeting notes.md create mode 100644 app/src/test/resources/import-fixtures/joplin/md_frontmatter/Work/Renew passport.md create mode 100644 app/src/test/resources/import-fixtures/joplin/notes.jex create mode 100644 app/src/test/resources/import-fixtures/joplin/raw/05e7fc905c484b89bd8d6aae13073c8f.md create mode 100644 app/src/test/resources/import-fixtures/joplin/raw/0b9a0c4713e54184a552af48e6b13460.md create mode 100644 app/src/test/resources/import-fixtures/joplin/raw/3d060bc9f9484e49b05a9f006cf362ab.md create mode 100644 app/src/test/resources/import-fixtures/joplin/raw/6ca5bdfec28c489c9d18a75584c72238.md create mode 100644 app/src/test/resources/import-fixtures/joplin/raw/70e99e28284f447fb84681d796a98d2f.md create mode 100644 app/src/test/resources/import-fixtures/joplin/raw/a0361945271f4eb5b2400c5e5ce4545a.md create mode 100644 app/src/test/resources/import-fixtures/joplin/raw/a3afd784a6344c2f90026d22a05adb76.md create mode 100644 app/src/test/resources/import-fixtures/joplin/raw/b3eaf8f8050a42178bb80c2e53e8fa34.md create mode 100644 app/src/test/resources/import-fixtures/joplin/raw/ba98e539389140aa90f4cd70fb226964.md create mode 100644 app/src/test/resources/import-fixtures/joplin/raw/c82ee80c86ff479c98f35a4e341e2164.md create mode 100644 app/src/test/resources/import-fixtures/joplin/raw/eaf85d5d4ff94f6c9b92eccf8ba453bf.md create mode 100644 app/src/test/resources/import-fixtures/joplin/raw/fa94f84f272643cfa3a74deb510ff642.md create mode 100644 app/src/test/resources/import-fixtures/joplin/raw/fe3af29373694b86b45af6ae9b2c6516.md create mode 100644 app/src/test/resources/import-fixtures/keep/README.md create mode 100644 app/src/test/resources/import-fixtures/keep/takeout-keep.zip create mode 100644 app/src/test/resources/import-fixtures/markdown/README.md create mode 100644 app/src/test/resources/import-fixtures/markdown/obsidian-vault/.obsidian/app.json create mode 100644 app/src/test/resources/import-fixtures/markdown/obsidian-vault/.obsidian/workspace.json create mode 100644 app/src/test/resources/import-fixtures/markdown/obsidian-vault/Attachments/image.png create mode 100644 app/src/test/resources/import-fixtures/markdown/obsidian-vault/Groceries.md create mode 100644 app/src/test/resources/import-fixtures/markdown/obsidian-vault/Markor note.txt create mode 100644 app/src/test/resources/import-fixtures/markdown/obsidian-vault/Work/Meeting notes.md create mode 100644 "app/src/test/resources/import-fixtures/markdown/obsidian-vault/\320\206\320\264\320\265\321\227 \360\237\222\241.md" create mode 100644 app/src/test/resources/import-fixtures/notesnook/README.md create mode 100644 app/src/test/resources/import-fixtures/notesnook/backup.nnbackupz create mode 100644 app/src/test/resources/import-fixtures/notesnook/notes-md-frontmatter.zip create mode 100644 app/src/test/resources/import-fixtures/notesnook/notes-md.zip create mode 100644 app/src/test/resources/import-fixtures/notesnook/notes-txt.zip create mode 100644 app/src/test/resources/import-fixtures/standard-notes/README.md create mode 100644 app/src/test/resources/import-fixtures/standard-notes/Standard Notes Backup - decrypted.zip diff --git a/CHANGELOG.md b/CHANGELOG.md index 0d0bc424..6f1fdd26 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,9 @@ IzzyOnDroid) and used as the GitHub Release notes. ### 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. - Compact line spacing for notes, with a persistent editor setting; Default preserves the existing layout. - 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 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8c9b0c4a..ec7870d6 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -104,8 +104,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,7 +116,7 @@ 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 +`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. @@ -139,7 +139,7 @@ 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, + `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/SECURITY.md b/SECURITY.md index 67c70b19..817cef07 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -374,6 +374,40 @@ 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: 64 MiB read, 64 MiB uncompressed, 4 MiB per file (16 MiB for + a JSON export that holds every note), 20 000 entries (directories included), 20 000 notes, path + depth 16, JSON nesting 64. Absolute paths, `..`, backslashes, NUL, links, devices and archives + inside archives are skipped and counted; a ZIP bomb or an endless archive ends as "too large". + 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; 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, 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. 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..149a0362 --- /dev/null +++ b/app/src/main/java/com/pasich/encly/data/importer/DocumentTree.kt @@ -0,0 +1,50 @@ +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, never deeper + * than [ImportLimits.maxPathDepth] and never listing more than [ImportLimits.maxEntries] entries in + * all, so a cyclic or enormous tree ends instead of looping. + */ +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 { + if (depth <= limits.maxPathDepth) { + for (node in tree.children(parentId).take(limits.maxEntries)) { + if (entriesLeft-- <= 0) break + val path = if (prefix.isEmpty()) node.name else "$prefix/${node.name}" + if (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..2db9bd39 --- /dev/null +++ b/app/src/main/java/com/pasich/encly/data/importer/FormatDetector.kt @@ -0,0 +1,170 @@ +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 = entry.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 = entry.extension in IGNORABLE && entry.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..17b436d8 --- /dev/null +++ b/app/src/main/java/com/pasich/encly/data/importer/Frontmatter.kt @@ -0,0 +1,96 @@ +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. + */ +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) { + MarkdownDocument(emptyMap(), text) + } else { + MarkdownDocument(parse(lines.subList(1, end)), lines.drop(end + 1).joinToString("\n")) + } + } + + 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..1cda7a2a --- /dev/null +++ b/app/src/main/java/com/pasich/encly/data/importer/ImportMapper.kt @@ -0,0 +1,238 @@ +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, path, Keep path + creation time), 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 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) } + 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 { id -> tags.values.first { it.uid == id }.name }, 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): 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) + return tags.getOrPut(key) { + BackupTag(uid = sha256Hex("encly-import/v1|tag|$key"), name = name, visible = true, position = tags.size) + }.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() + + private fun textOf(note: ImportedNote): String = note.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, kinds, 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 + } + + 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 } + val parsed = when (source) { + ImportSource.GOOGLE_KEEP -> KeepParser.parse(usable, limits) + ImportSource.STANDARD_NOTES -> StandardNotesParser.parse(usable, limits) + ImportSource.JOPLIN -> JoplinParser.parse(usable, limits) + ImportSource.NOTESNOOK -> MarkdownNotesParser.parse(usable, limits, notesnook = true) + ImportSource.MARKDOWN -> MarkdownNotesParser.parse(usable, limits, notesnook = false) + } + val withEncrypted = ParsedNotes( + parsed.notes, + parsed.skips.copy(unsupported = parsed.skips.unsupported + encrypted), + 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..94026e1d --- /dev/null +++ b/app/src/main/java/com/pasich/encly/data/importer/ImportReader.kt @@ -0,0 +1,217 @@ +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. */ +class SourceFile(val path: String, 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. */ +class ReadResult(val entries: List, val skips: ReadSkips) + +/** + * 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. + */ +class ImportReader(private val limits: ImportLimits = ImportLimits()) { + + fun read(source: ImportFileSource, checkCancelled: () -> Unit = {}): ReadResult = + Session(limits, checkCancelled).run(source) + + private class Session(private val limits: ImportLimits, private val checkCancelled: () -> Unit) { + private val entries = ArrayList() + private var seen = 0 + 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() + val stream = try { + file.open() + } catch (_: IOException) { + null + } catch (_: SecurityException) { + null + } + if (stream == null) { + unreadable++ + continue + } + try { + stream.use { process(file.path, Counting(it)) } + } catch (_: IOException) { + unreadable++ + } catch (_: IllegalArgumentException) { + // A ZIP entry name that is not valid text. + unreadable++ + } + } + return ReadResult(entries, ReadSkips(unsafe, nested, tooLarge, 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 -> accept(path, whole) + } + } + + /** 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 + if (entry.isDirectory) skipDirectory(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 + when (header.type) { + TarReader.DIRECTORY -> Unit + + TarReader.REGULAR, TarReader.REGULAR_OLD -> 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) { + if (++seen > limits.maxEntries) throw ImportException(ImportError.TOO_LARGE) + 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) { + if (++seen > limits.maxEntries) throw ImportException(ImportError.TOO_LARGE) + // A directory carries no data; one that does is not a file to import. + if (readBounded(input, 0) == null) unsafe++ + } + + /** 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, or null if it is unsafe: + * absolute, a drive letter, `..`, a backslash or NUL, or deeper than [maxDepth]. + */ + fun safePath(raw: String, maxDepth: Int = ImportLimits.MAX_PATH_DEPTH): String? { + val segments = raw.split('/').filter { it.isNotEmpty() && it != "." } + val unsafe = raw.any { it == '\u0000' || it == '\\' } || raw.startsWith("/") || + DRIVE.containsMatchIn(raw) || segments.isEmpty() || segments.size > maxDepth || ".." in segments + 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..f3ba9973 --- /dev/null +++ b/app/src/main/java/com/pasich/encly/data/importer/ImportTypes.kt @@ -0,0 +1,167 @@ +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). */ + val maxInputBytes: Long = MAX_TOTAL_BYTES, + /** Bytes after decompression, over every entry (skipped ones included). */ + val maxTotalBytes: Long = MAX_TOTAL_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_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..b8c09fdb --- /dev/null +++ b/app/src/main/java/com/pasich/encly/data/importer/LexicalBlocks.kt @@ -0,0 +1,93 @@ +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, rules and (flattened) lists become blocks; any other node contributes its text. + */ +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) + "horizontalrule" -> blocks += Block.SeparatorBlock() + "list" -> list(node, depth) + else -> other(node, depth) + } + } + + private fun other(node: JsonObject, depth: Int) { + val children = children(node) + if (children.isEmpty()) { + add(ImportText.string(node["text"]).orEmpty(), 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" -> append(ImportText.string(current["text"]).orEmpty()) + "linebreak" -> append('\n') + "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 + + /** [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..147f0eb6 --- /dev/null +++ b/app/src/main/java/com/pasich/encly/data/importer/MarkdownBlockMapper.kt @@ -0,0 +1,180 @@ +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; text is never dropped. + * + * - `#`..`####` -> 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, so no raw markup reaches the editor. + */ +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]*""") + private val WIKI_LINK = Regex("""!?\[\[([^\]|\n]+)(?:\|([^\]\n]+))?]]""") + private val BULLET = Regex("""^[ \t]*[-*+][ \t]+(.*)$""") + private val NUMBER = Regex("""^[ \t]*\d{1,9}[.)][ \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() + + /** [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] } }, + ) + + private class Builder { + 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) { + val fence = FENCE.matches(raw) + when { + inFence && fence -> closeFence() + + inFence -> code += raw + + fence -> { + flushAll() + inFence = true + } + + 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) + when { + SEPARATOR.matches(raw) -> { + flushAll() + blocks += Block.SeparatorBlock() + } + + heading != null -> { + flushAll() + val level = heading.groupValues[1].length + blocks += ImportBlocks.heading(inline(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 += inline(CALLOUT.replaceFirst(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 + " " + inline(raw.trim())).trim()) + } + + else -> { + flushList() + flushQuote() + paragraph += inline(raw.trimEnd()) + } + } + } + + private fun item(type: BlockType, text: String, checked: Boolean) { + flushParagraph() + flushQuote() + if (listType != type) flushList() + listType = type + items += ItemListBlock(value = inline(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 + val kept = 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..93f6ab77 --- /dev/null +++ b/app/src/main/java/com/pasich/encly/data/importer/MarkdownNotesParser.kt @@ -0,0 +1,95 @@ +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. + * - 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 + + fun parse(entries: List, limits: ImportLimits, notesnook: Boolean): ParsedNotes { + val context = ParseContext(limits) + var attachments = 0 + for (entry in entries) { + val dirs = entry.path.split('/').dropLast(1) + when { + (notesnook && ATTACHMENTS_DIR in dirs) || ImportKinds.isMedia(entry) -> attachments++ + isTextFile(entry) -> context.text(entry)?.let { context.add(note(entry, it, dirs, notesnook)) } + 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)) } + return ImportedNote( + sourceKey = entry.path, + title = titled.title, + blocks = MarkdownBlockMapper.toBlocks(titled.body), + 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..2f6ad891 --- /dev/null +++ b/app/src/main/java/com/pasich/encly/data/importer/ZipStreamReader.kt @@ -0,0 +1,271 @@ +package com.pasich.encly.data.importer + +import java.io.IOException +import java.io.InputStream +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; 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 or the end; unread data of the last one is dropped. */ + fun next(): Entry? { + state?.let { drain(it) } + state = null + val signature = readIntOrNull() + val header = if (signature == LOCAL_HEADER) readExact(LOCAL_FIXED) else null + return header?.let(::open) + } + + /** 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 = String(readExact(u16(header, NAME_LENGTH)), Charsets.UTF_8) + 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) + } + + /** 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 { + const val BUFFER = 16 * 1024 + const val MAX_DRAIN = 1024 * 1024 + const val LOCAL_HEADER = 0x04034b50L + const val DESCRIPTOR_SIGNATURE = 0x08074b50L + 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/presentation/components/settings/SettingsBuilder.kt b/app/src/main/java/com/pasich/encly/presentation/components/settings/SettingsBuilder.kt index 9fd95d5f..e1627fa6 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, ) @@ -46,6 +47,15 @@ class SettingsBuilder { ) { navigation.onBackup() }.takeIf { backupAvailable }, + // Import goes into whichever vault is open, so the decoy vault has it too. + 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/navigation/NavHost.kt b/app/src/main/java/com/pasich/encly/presentation/navigation/NavHost.kt index fe503473..8ca5ec9c 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 @@ -24,6 +24,7 @@ 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 @@ -109,6 +110,10 @@ fun AppNavHost(navController: NavHostController, startDestination: String = NavR RealVaultOnly(navController) { 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. 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 ac315e01..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 @@ -21,6 +21,7 @@ enum class NavRoutes { 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/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..1ebae59f --- /dev/null +++ b/app/src/main/java/com/pasich/encly/presentation/screen/importnotes/ImportPages.kt @@ -0,0 +1,142 @@ +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.EnclyCard +import com.pasich.encly.presentation.designsystem.EnclyCardStyle +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)) + ImportSource.entries.forEach { SourceCard(it) } + } + } +} + +/** One supported app: what to pick, how to get it out of the app, and what will not come over. */ +@Composable +internal fun SourceCard(source: ImportSource, modifier: Modifier = Modifier) { + EnclyCard(modifier = modifier, style = EnclyCardStyle.OUTLINED) { + Text(text = stringResource(source.title), style = MaterialTheme.typography.titleMedium) + Text( + text = stringResource(source.formats), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Labelled(stringResource(R.string.import_label_how_to), stringResource(source.howTo)) + Labelled(stringResource(R.string.import_label_losses), stringResource(source.losses)) + } +} + +@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..6fd8a6d4 --- /dev/null +++ b/app/src/main/java/com/pasich/encly/presentation/screen/importnotes/ImportReviewScreen.kt @@ -0,0 +1,209 @@ +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), + ) + val all = review.newCount > 0 && 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/settings/SettingsScreen.kt b/app/src/main/java/com/pasich/encly/presentation/screen/settings/SettingsScreen.kt index a6a2f4a6..709d8e22 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 @@ -50,6 +50,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/viewmodel/ImportNotesViewModel.kt b/app/src/main/java/com/pasich/encly/presentation/viewmodel/ImportNotesViewModel.kt new file mode 100644 index 00000000..5958cf87 --- /dev/null +++ b/app/src/main/java/com/pasich/encly/presentation/viewmodel/ImportNotesViewModel.kt @@ -0,0 +1,339 @@ +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 { _step.value = read(store, source) } + } + + private suspend fun read(store: VaultDataStore, source: ImportFileSource): ImportStep = 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) { + failed(failureOf(e.error)) + } catch (_: IOException) { + failed(ImportFailure.UNREADABLE) + } catch (_: SecurityException) { + failed(ImportFailure.UNREADABLE) + } catch (_: OutOfMemoryError) { + failed(ImportFailure.TOO_LARGE) + } catch (_: RuntimeException) { + // The vault closed while reading, or the snapshot failed. The cause can quote content. + failed(ImportFailure.VAULT) + } + + private suspend fun show(analysis: Analysis, read: ReadResult, store: VaultDataStore): ImportStep = + when (analysis) { + is Analysis.Ready -> { + val existing = store.snapshot().notes.mapTo(HashSet()) { it.uid } + val ready = analysis.plan.withExisting(existing) + plan = ready + pending = null + ImportStep.Preview(ready.preview, ReviewState.of(ready.rows)) + } + + Analysis.Ambiguous -> { + // Kept in memory so the user's choice can be applied without reading again. + pending = read + ImportStep.ChooseSource + } + + is Analysis.Encrypted -> failed( + if (analysis.source == ImportSource.NOTESNOOK) { + ImportFailure.ENCRYPTED_NOTESNOOK + } else { + ImportFailure.ENCRYPTED_STANDARD_NOTES + }, + ) + + is Analysis.Failed -> failed(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 { + _step.value = try { + withContext(ioDispatcher) { show(ImportAnalyzer.analyze(read, forced = source), read, store) } + } catch (e: CancellationException) { + throw e + } catch (e: ImportException) { + failed(failureOf(e.error)) + } catch (_: RuntimeException) { + failed(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/res/values-de/strings_import.xml b/app/src/main/res/values-de/strings_import.xml new file mode 100644 index 00000000..7817bd51 --- /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 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-es/strings_import.xml b/app/src/main/res/values-es/strings_import.xml new file mode 100644 index 00000000..2bbdc515 --- /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 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-fr/strings_import.xml b/app/src/main/res/values-fr/strings_import.xml new file mode 100644 index 00000000..39c8ad72 --- /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 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-it/strings_import.xml b/app/src/main/res/values-it/strings_import.xml new file mode 100644 index 00000000..c7e8f4c7 --- /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 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-nl/strings_import.xml b/app/src/main/res/values-nl/strings_import.xml new file mode 100644 index 00000000..f4a427c1 --- /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 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-pl/strings_import.xml b/app/src/main/res/values-pl/strings_import.xml new file mode 100644 index 00000000..b8ecd596 --- /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 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-pt/strings_import.xml b/app/src/main/res/values-pt/strings_import.xml new file mode 100644 index 00000000..f91acdb7 --- /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 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-uk/strings_import.xml b/app/src/main/res/values-uk/strings_import.xml new file mode 100644 index 00000000..afe11d74 --- /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 МБ або 20 000 нотаток). Імпортуйте його частинами. + У цьому виборі не знайдено нотаток. + Не вдалося прочитати вибрані файли. + На цьому телефоні немає застосунку для вибору файлів, який може використати Encly. + Сховище закрилося під час імпорту. Розблокуйте Encly і спробуйте ще раз. + Щось пішло не так, нічого не імпортовано. Ваше сховище не змінилося. + Вибрано %1$d з %2$d + Вибрати всі + Зняти вибір + Усі + Лише нові + У кошику + Без назви + Чекліст + У кошику + Уже в сховищі + Імпортувати: %1$d + Обрати ще раз + 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..89d884eb --- /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 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/test/java/com/pasich/encly/data/importer/ExpectedNote.kt b/app/src/test/java/com/pasich/encly/data/importer/ExpectedNote.kt new file mode 100644 index 00000000..79de56a7 --- /dev/null +++ b/app/src/test/java/com/pasich/encly/data/importer/ExpectedNote.kt @@ -0,0 +1,35 @@ +package com.pasich.encly.data.importer + +import com.pasich.encly.core.backup.BackupPayloadCodec +import com.pasich.encly.core.serialization.BlockConverter +import com.pasich.encly.data.importer.ImportFixtures.describe +import org.junit.Assert.assertEquals + +internal class ExpectedNote( + val title: String, + val blocks: List, + val tag: String? = null, + val created: Long?, + val edited: Long?, + val trashed: Boolean = false, + val losses: ImportLosses = ImportLosses(), +) + +internal fun checkPlan(plan: ImportPlan, expected: List, attachments: Int = 0) { + val rows = plan.payload.notes + assertEquals(expected.map { it.title }.sorted(), rows.map { it.title }.sorted()) + val tagNames = plan.payload.tags.associate { it.uid to it.name } + for (want in expected) { + val note = rows.first { it.title == want.title } + assertEquals(want.title, want.blocks, describe(BlockConverter.jsonToBlocks(note.value))) + assertEquals(want.title, want.tag, note.tagUid?.let(tagNames::get)) + if (want.created != null) assertEquals(want.title, want.created, note.dateCreate) + if (want.edited != null) assertEquals(want.title, want.edited, note.date) + assertEquals(want.title, want.trashed, note.isTrash) + } + BackupPayloadCodec.validate(plan.payload) + assertEquals( + expected.fold(ImportLosses(attachments = attachments)) { sum, note -> sum + note.losses }, + plan.preview.losses, + ) +} diff --git a/app/src/test/java/com/pasich/encly/data/importer/FormatDetectorTest.kt b/app/src/test/java/com/pasich/encly/data/importer/FormatDetectorTest.kt new file mode 100644 index 00000000..e66ac207 --- /dev/null +++ b/app/src/test/java/com/pasich/encly/data/importer/FormatDetectorTest.kt @@ -0,0 +1,126 @@ +package com.pasich.encly.data.importer + +import com.pasich.encly.data.importer.ImportFixtures.entry +import org.junit.Assert.assertEquals +import org.junit.Test + +class FormatDetectorTest { + private fun detect(vararg entries: ImportEntry) = FormatDetector.detect(entries.toList()) + + private val keep = """{"createdTimestampUsec":1,"userEditedTimestampUsec":2,"title":"t","textContent":"x"}""" + private val sn = """{"version":"004","items":[""" + + """{"content_type":"Note","uuid":"u","content":{"title":"t","text":"x"}}]}""" + private val joplin = "Title\n\nBody\n\nid: 0123456789abcdef0123456789abcdef\nparent_id: \ntype_: 1" + private val notesnook = "---\ntitle: \"T\"\ncreated_at: 2024-01-01T00:00:00Z\n" + + "updated_at: 2024-01-02T00:00:00Z\n---\n\n# T\n\nbody" + + @Test + fun recognisesEachSourceByContent() { + assertEquals(Detection.Found(ImportSource.GOOGLE_KEEP), detect(entry("Takeout/Keep/a.json", keep))) + assertEquals(Detection.Found(ImportSource.STANDARD_NOTES), detect(entry("b.json", sn))) + assertEquals(Detection.Found(ImportSource.JOPLIN), detect(entry("x.md", joplin))) + assertEquals(Detection.Found(ImportSource.NOTESNOOK), detect(entry("x.md", notesnook))) + assertEquals(Detection.Found(ImportSource.MARKDOWN), detect(entry("x.md", "# hello\n\nworld"))) + assertEquals(Detection.Found(ImportSource.MARKDOWN), detect(entry("x.txt", "plain"))) + } + + @Test + fun theExtensionAloneDecidesNothing() { + // JSON named .txt, Keep note named .md: only content counts. + val backup = entry("Standard Notes Backup and Import File.txt", sn) + assertEquals(Detection.Found(ImportSource.STANDARD_NOTES), detect(backup)) + assertEquals(Detection.Unsupported, detect(entry("note.json", "not json at all"))) + assertEquals(Detection.Found(ImportSource.MARKDOWN), detect(entry("keep.md", keep))) + } + + @Test + fun lookAlikesAreNotGuessed() { + assertEquals(Detection.Unsupported, detect(entry("a.json", """{"foo":"bar"}"""))) + assertEquals(Detection.Unsupported, detect(entry("a.json", "[1,2,3]"))) + // Keep-like with a missing key, or a non-numeric one. + assertEquals(Detection.Unsupported, detect(entry("a.json", """{"createdTimestampUsec":1,"title":"x"}"""))) + assertEquals( + Detection.Unsupported, + detect(entry("a.json", """{"createdTimestampUsec":"1","userEditedTimestampUsec":"2"}""")), + ) + // {version, items} without typed items. + assertEquals(Detection.Unsupported, detect(entry("a.json", """{"version":"1","items":[]}"""))) + // A Joplin-like block without the id. + val noId = entry("a.md", "T\n\nbody\n\nparent_id: x\ntype_: 1") + assertEquals(Detection.Found(ImportSource.MARKDOWN), detect(noId)) + } + + @Test + fun encryptedStandardNotesIsRefused() { + val byKeyParams = """{"version":"004","items":[],"keyParams":{"identifier":"a"}}""" + val legacy = """{"version":"003","items":[],"auth_params":{"x":1}}""" + val byContent = """{"version":"004","items":[{"content_type":"Note","uuid":"u","content":"004:abc"}]}""" + listOf(byKeyParams, legacy, byContent).forEach { + assertEquals(Detection.Encrypted(ImportSource.STANDARD_NOTES), detect(entry("b.json", it))) + } + } + + @Test + fun notesnookBackupsAreRefused() { + assertEquals(Detection.Encrypted(ImportSource.NOTESNOOK), detect(entry("my.nnbackup", "x"))) + assertEquals(Detection.Encrypted(ImportSource.NOTESNOOK), detect(entry(".nnbackup", ""))) + val chunk = """{"version":1,"type":"backup","date":1,"data":"abc","hash":"x",""" + + """"hash_type":"md5","compressed":true,"encrypted":false}""" + assertEquals(Detection.Encrypted(ImportSource.NOTESNOOK), detect(entry("backup/chunk.json", chunk))) + } + + @Test + fun filesOfSeveralAppsAreAmbiguous() { + assertEquals(Detection.Ambiguous, detect(entry("a.json", keep), entry("b.md", joplin))) + assertEquals(Detection.Ambiguous, detect(entry("a.json", sn), entry("b.md", notesnook))) + } + + @Test + fun plainTextBesideASpecificExportDoesNotMakeItAmbiguous() { + // Keep's Takeout has a Labels.txt beside the notes. + val labels = entry("Takeout/Keep/Labels.txt", "a\nb") + assertEquals(Detection.Found(ImportSource.GOOGLE_KEEP), detect(labels, entry("Takeout/Keep/n.json", keep))) + } + + @Test + fun theItemsFolderBesideAStandardNotesBackupIsIgnored() { + val detection = detect( + entry("Standard Notes Backup and Import File.txt", sn), + entry("Items/Note/Plan-1234.txt", "text of the note"), + ) + assertEquals(Detection.Found(ImportSource.STANDARD_NOTES), detection) + } + + @Test + fun hiddenFoldersAndMediaAreIgnored() { + val detection = detect( + entry(".obsidian/app.json", """{"x":1}"""), + entry("img.png", "x"), + entry("page.html", "

x

"), + entry("note.md", "text"), + ) + assertEquals(Detection.Found(ImportSource.MARKDOWN), detection) + } + + @Test + fun nothingUsableIsNotGuessed() { + assertEquals(Detection.Empty, detect()) + assertEquals(Detection.Unsupported, detect(entry("img.png", "x"))) + assertEquals(Detection.Unsupported, detect(entry("data.csv", "a,b"))) + val badBytes = byteArrayOf(0xff.toByte(), 0xfe.toByte(), 0x41) + assertEquals(Detection.Unsupported, detect(ImportEntry("bad.md", badBytes))) + } + + @Test + fun aManualChoiceStillHasToValidate() { + val kinds = FormatDetector.classify(listOf(entry("a.json", sn)), ImportLimits()) + val notes = ImportSource.STANDARD_NOTES + assertEquals(Detection.Found(notes), FormatDetector.validate(notes, kinds)) + assertEquals(Detection.Unsupported, FormatDetector.validate(ImportSource.GOOGLE_KEEP, kinds)) + assertEquals(Detection.Unsupported, FormatDetector.validate(ImportSource.JOPLIN, kinds)) + val locked = entry("a.json", """{"version":"1","items":[],"keyParams":{}}""") + val encrypted = FormatDetector.classify(listOf(locked), ImportLimits()) + assertEquals(Detection.Encrypted(notes), FormatDetector.validate(notes, encrypted)) + assertEquals(Detection.Empty, FormatDetector.validate(ImportSource.MARKDOWN, emptyList())) + } +} diff --git a/app/src/test/java/com/pasich/encly/data/importer/FrontmatterTest.kt b/app/src/test/java/com/pasich/encly/data/importer/FrontmatterTest.kt new file mode 100644 index 00000000..76f9217e --- /dev/null +++ b/app/src/test/java/com/pasich/encly/data/importer/FrontmatterTest.kt @@ -0,0 +1,63 @@ +package com.pasich.encly.data.importer + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class FrontmatterTest { + @Test + fun readsScalarsListsAndInlineListsAndIgnoresTheRest() { + val doc = Frontmatter.split( + """ + --- + title: "A \"quoted\" title" + created: 2024-03-01 + tags: + - one + - 'two' + aliases: [x, y] + nested: + deep: value + note: | + block text + --- + body here + """.trimIndent(), + ) + assertEquals("A \"quoted\" title", doc.value("title")) + assertEquals("2024-03-01", doc.value("created")) + assertEquals(listOf("one", "two"), doc.list("tags")) + assertEquals(listOf("x", "y"), doc.list("aliases")) + assertFalse(doc.has("deep")) + assertEquals("body here", doc.body) + } + + @Test + fun aCommaSeparatedScalarIsAList() { + val doc = Frontmatter.split("---\ntags: a, b ,c\n---\nx") + assertEquals(listOf("a", " b ", "c").map { it }, doc.list("tags")) + } + + @Test + fun withoutAClosingMarkerTheWholeFileIsBody() { + val text = "---\ntitle: x\nno end" + val doc = Frontmatter.split(text) + assertFalse(doc.hasFrontmatter) + assertEquals(text, doc.body) + } + + @Test + fun aFileThatDoesNotStartWithTheMarkerHasNone() { + val doc = Frontmatter.split("intro\n---\ntitle: x\n---\n") + assertNull(doc.value("title")) + assertTrue(doc.body.startsWith("intro")) + } + + @Test + fun aCommentOrAnchorIsNeverExecuted() { + val doc = Frontmatter.split("---\nrun: !!python/object/apply:os.system [\"x\"]\ntitle: ok\n---\n") + assertEquals("ok", doc.value("title")) + } +} diff --git a/app/src/test/java/com/pasich/encly/data/importer/ImportFixtures.kt b/app/src/test/java/com/pasich/encly/data/importer/ImportFixtures.kt new file mode 100644 index 00000000..0e741e57 --- /dev/null +++ b/app/src/test/java/com/pasich/encly/data/importer/ImportFixtures.kt @@ -0,0 +1,157 @@ +package com.pasich.encly.data.importer + +import com.pasich.encly.dynamicBlocks.Block +import com.pasich.encly.dynamicBlocks.BlockType +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.util.zip.ZipEntry +import java.util.zip.ZipOutputStream + +/** One member of a test TAR; [type] is the typeflag ('0' file, '2' symlink, '5' directory, ...). */ +internal class TarFile(val name: String, val data: ByteArray = ByteArray(0), val type: Char = '0') + +/** Builds inputs for the import tests in memory, and reads the real exports under `import-fixtures`. */ +internal object ImportFixtures { + fun entry(path: String, text: String) = ImportEntry(path, text.toByteArray(Charsets.UTF_8)) + + fun source(vararg files: Pair): ImportFileSource = ImportFileSource { + files.asSequence().map { (path, bytes) -> SourceFile(path) { ByteArrayInputStream(bytes) } } + } + + fun textSource(vararg files: Pair): ImportFileSource = + source(*files.map { (path, text) -> path to text.toByteArray(Charsets.UTF_8) }.toTypedArray()) + + fun zip(vararg files: Pair, store: Boolean = false): ByteArray { + val out = ByteArrayOutputStream() + ZipOutputStream(out).use { zip -> + if (store) zip.setLevel(0) + for ((name, bytes) in files) { + zip.putNextEntry(ZipEntry(name)) + zip.write(bytes) + zip.closeEntry() + } + } + return out.toByteArray() + } + + fun zipText(vararg files: Pair): ByteArray = + zip(*files.map { (path, text) -> path to text.toByteArray(Charsets.UTF_8) }.toTypedArray()) + + /** A ustar archive; a name longer than 100 characters is written as a GNU long-name entry. */ + fun tar(vararg files: TarFile): ByteArray { + val out = ByteArrayOutputStream() + for (file in files) { + var name = file.name + if (name.toByteArray().size > NAME_LENGTH) { + out.write(header("././@LongLink", file.name.toByteArray().size + 1L, 'L')) + out.write(pad(file.name.toByteArray() + 0)) + name = name.take(NAME_LENGTH) + } + out.write(header(name, file.data.size.toLong(), file.type)) + out.write(pad(file.data)) + } + out.write(ByteArray(BLOCK * 2)) + return out.toByteArray() + } + + private const val BLOCK = 512 + private const val NAME_LENGTH = 100 + + private fun pad(data: ByteArray): ByteArray { + val rest = (BLOCK - data.size % BLOCK) % BLOCK + return data + ByteArray(rest) + } + + private fun header(name: String, size: Long, type: Char): ByteArray { + val block = ByteArray(BLOCK) + fun put(offset: Int, text: String) = text.toByteArray(Charsets.ISO_8859_1).copyInto(block, offset) + put(0, name) + put(100, "0000644\u0000") + put(108, "0000000\u0000") + put(116, "0000000\u0000") + put(124, "%011o\u0000".format(size)) + put(136, "00000000000\u0000") + put(148, " ") + block[156] = type.code.toByte() + put(257, "ustar\u000000") + val sum = block.sumOf { it.toInt() and 0xff } + put(148, "%06o\u0000 ".format(sum)) + return block + } + + /** A real export file under src/test/resources/import-fixtures, or null if it is not there (yet). */ + fun resource(path: String): ByteArray? = + ImportFixtures::class.java.classLoader?.getResourceAsStream("import-fixtures/$path")?.use { it.readBytes() } + + /** The real files of a fixture folder as a source, paths relative to [folder]; null if absent. */ + fun resourceFolder(folder: String, names: List): ImportFileSource? { + val files = names.map { it to (resource("$folder/$it") ?: return null) } + return source(*files.toTypedArray()) + } + + /** A directory as a [DocumentTree]: document ids are relative paths. */ + private class FileTree(private val dir: java.io.File) : DocumentTree { + override val rootId = "" + + override fun children(parentId: String): List = + java.io.File(dir, parentId).listFiles().orEmpty().sortedBy { it.name }.map { + TreeNode(it.relativeTo(dir).invariantSeparatorsPath, it.name, it.isDirectory) + } + + override fun open(id: String) = java.io.File(dir, id).inputStream() + } + + /** A real fixture folder, walked by the same [TreeWalker] the Storage Access Framework tree uses. */ + fun resourceTree(folder: String): ImportFileSource? { + val dir = ImportFixtures::class.java.classLoader?.getResource("import-fixtures/$folder") + ?.let { java.io.File(it.toURI()) }?.takeIf { it.isDirectory } ?: return null + return TreeWalker(FileTree(dir)).source() + } + + fun read(source: ImportFileSource, limits: ImportLimits = ImportLimits()): ReadResult = + ImportReader(limits).read(source) + + fun analyze(source: ImportFileSource, forced: ImportSource? = null): Analysis = + ImportAnalyzer.analyze(read(source), forced, now = NOW) + + fun ready(source: ImportFileSource, forced: ImportSource? = null): ImportPlan = + (analyze(source, forced) as Analysis.Ready).plan + + const val NOW = 1_800_000_000_000L + + /** Blocks as short strings for exact assertions. */ + fun describe(blocks: List): List = blocks.map { block -> + when (block) { + is Block.TextBlock -> "text:${block.text.value}" + + is Block.HBlock -> "${block.blockType.name.lowercase()}:${block.text.value}" + + is Block.QuoteBlock -> "quote:${block.text.value}" + + is Block.SeparatorBlock -> "separator" + + is Block.LinkBlock -> "link" + + is Block.ListBlock -> { + val kind = when (block.blockType) { + BlockType.LIST_CHECK -> "check" + BlockType.LIST_NUMBER -> "number" + else -> "bullet" + } + "$kind:" + block.items.value.joinToString("|") { (if (it.isCheck) "[x] " else "[ ] ") + it.value } + } + } + } + + fun parse(source: ImportSource, vararg entries: ImportEntry): ParsedNotes { + val list = entries.toList() + val limits = ImportLimits() + return when (source) { + ImportSource.GOOGLE_KEEP -> KeepParser.parse(list, limits) + ImportSource.STANDARD_NOTES -> StandardNotesParser.parse(list, limits) + ImportSource.JOPLIN -> JoplinParser.parse(list, limits) + ImportSource.NOTESNOOK -> MarkdownNotesParser.parse(list, limits, notesnook = true) + ImportSource.MARKDOWN -> MarkdownNotesParser.parse(list, limits, notesnook = false) + } + } +} diff --git a/app/src/test/java/com/pasich/encly/data/importer/ImportMapperTest.kt b/app/src/test/java/com/pasich/encly/data/importer/ImportMapperTest.kt new file mode 100644 index 00000000..37d4dddb --- /dev/null +++ b/app/src/test/java/com/pasich/encly/data/importer/ImportMapperTest.kt @@ -0,0 +1,178 @@ +package com.pasich.encly.data.importer + +import com.pasich.encly.core.backup.BackupPayloadCodec +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.importer.ImportFixtures.NOW +import com.pasich.encly.dynamicBlocks.Block +import com.pasich.encly.testutil.InMemoryVaultDataStore +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class ImportMapperTest { + private fun text(value: String): Block = Block.TextBlock(text = MutableStateFlow(value)) + + private fun map(vararg notes: ImportedNote, source: ImportSource = ImportSource.MARKDOWN): ImportPlan = + ImportMapper.map(ParsedNotes(notes.toList(), ImportSkips()), source, ReadSkips(), NOW) + + private fun note(title: String, body: String, key: String? = null, tags: List = emptyList()) = + ImportedNote(sourceKey = key, title = title, blocks = listOf(text(body)), tags = tags) + + private val keepFiles = ImportFixtures.textSource( + "a.json" to """{"createdTimestampUsec":1000000000000000,"userEditedTimestampUsec":1000000001000000,""" + + """"title":"A","textContent":"alpha","labels":[{"name":"Work"}]}""", + "b.json" to """{"createdTimestampUsec":1000000002000000,"userEditedTimestampUsec":1000000003000000,""" + + """"title":"B","textContent":"beta"}""", + ) + + @Test + fun theMappedPayloadIsValidAndUsesEnclysRecords() { + val plan = ImportFixtures.ready(keepFiles) + BackupPayloadCodec.validate(plan.payload) + assertEquals(listOf("A", "B"), plan.payload.notes.map { it.title }) + assertEquals(listOf("Work"), plan.payload.tags.map { it.name }) + assertEquals(1_000_000_001_000L, plan.payload.notes[0].date) + assertEquals(1_000_000_000_000L, plan.payload.notes[0].dateCreate) + assertTrue(plan.payload.tasks.isEmpty()) + assertTrue(plan.payload.notes.all { it.uid.length == 64 }) + } + + @Test + fun aMissingDateFallsBackToTheOtherOrToNow() { + val plan = map( + ImportedNote("a", "only created", listOf(text("x")), created = 5), + ImportedNote("b", "only edited", listOf(text("x")), edited = 7), + ImportedNote("c", "none", listOf(text("x"))), + ) + assertEquals(listOf(5L to 5L, 7L to 7L, NOW to NOW), plan.payload.notes.map { it.date to it.dateCreate }) + } + + @Test + fun importingTheSameExportTwiceAddsNothingTheSecondTime() = runTest { + val store = InMemoryVaultDataStore() + val first = ImportFixtures.ready(keepFiles) + val one = BackupImporter.import(first.payload, ImportMode.MERGE, store, TagMatch.UID_OR_NAME) + assertEquals(2, one.notesAdded) + + val again = ImportFixtures.ready(keepFiles) + assertEquals(first.payload.notes.map { it.uid }, again.payload.notes.map { it.uid }) + val two = BackupImporter.import(again.payload, ImportMode.MERGE, store, TagMatch.UID_OR_NAME) + assertEquals(0, two.notesAdded) + assertEquals(0, two.tagsAdded) + assertEquals(2, store.notes.size) + assertEquals(1, store.tags.size) + } + + @Test + fun theSameTextWithDifferentTitlesOrPathsStaysDistinct() { + val byTitle = map(note("One", "same body"), note("Two", "same body")) + assertEquals(2, byTitle.payload.notes.size) + val byPath = map(note("Same", "same body", key = "a/Same.md"), note("Same", "same body", key = "b/Same.md")) + assertEquals(2, byPath.payload.notes.size) + } + + @Test + fun theSameKeyOrTheSameTitleAndTextCollapsesAndIsCounted() { + val plan = + map(note("T", "body here"), note("t", "BODY here"), note("X", "x", key = "k"), note("Y", "y", key = "k")) + assertEquals(2, plan.payload.notes.size) + assertEquals(2, plan.preview.duplicatesInSelection) + } + + @Test + fun theUidDependsOnTheSource() { + val a = map(note("T", "b", key = "k"), source = ImportSource.JOPLIN).payload.notes.single().uid + val b = map(note("T", "b", key = "k"), source = ImportSource.NOTESNOOK).payload.notes.single().uid + assertNotEquals(a, b) + } + + @Test + fun theFirstTagIsKeptNamesAreMatchedAndCutLikeTheTagEditor() { + val plan = map( + note("a", "x", tags = listOf(" Work Stuff ", "second")), + note("b", "x2", tags = listOf("work stuff")), + note("c", "x3", tags = listOf("a".repeat(40))), + ) + assertEquals(listOf("Work Stuff", "a".repeat(20)), plan.payload.tags.map { it.name }) + val uids = plan.payload.notes.map { it.tagUid } + assertEquals(uids[0], uids[1]) + assertNotEquals(uids[0], uids[2]) + } + + @Test + fun aTagTheVaultAlreadyHasIsReusedByNameNotDuplicated() = runTest { + val store = InMemoryVaultDataStore() + store.insertTag(com.pasich.encly.data.model.Tag(nameTag = "WORK", position = 0, uid = "existing")) + BackupImporter.import(ImportFixtures.ready(keepFiles).payload, ImportMode.MERGE, store, TagMatch.UID_OR_NAME) + assertEquals(1, store.tags.size) + assertEquals(store.tags.single().id, store.notes.first { it.title == "A" }.tagId) + } + + @Test + fun onlyTagsOfImportedNotesAreCreated() { + val plan = map(ImportedNote("k", "T", listOf(text("x")), tags = emptyList())) + assertTrue(plan.payload.tags.isEmpty()) + } + + @Test + fun selectionKeepsOnlyChosenNotesAndTheirTags() { + val plan = ImportFixtures.ready(keepFiles) + val b = plan.payload.notes.first { it.title == "B" }.uid + val chosen = ImportReview.selection(plan.payload, setOf(b)) + assertEquals(listOf("B"), chosen.notes.map { it.title }) + assertTrue(chosen.tags.isEmpty()) + BackupPayloadCodec.validate(chosen) + } + + @Test + fun anImportThatFindsNoNoteIsNothingFound() { + val analysis = ImportFixtures.analyze( + ImportFixtures.textSource("a.json" to """{"createdTimestampUsec":1,"userEditedTimestampUsec":1}"""), + ) + assertEquals(Analysis.Failed(ImportError.NOTHING_FOUND), analysis) + } + + @Test + fun tooManyNotesIsRefusedEvenWhenTheFilesAreSmall() { + val limits = ImportLimits(maxNotes = 1) + val read = ImportFixtures.read(keepFiles) + assertEquals(Analysis.Failed(ImportError.TOO_LARGE), ImportAnalyzer.analyze(read, limits = limits)) + } + + @Test + fun aNestedArchiveAndAnUnsafePathAreReportedInThePreview() { + val inner = ImportFixtures.zipText("x.md" to "x") + val outer = ImportFixtures.zip("../evil.md" to ByteArray(1), "in.zip" to inner, "ok.md" to "ok".toByteArray()) + val plan = ImportFixtures.ready(ImportFixtures.source("o.zip" to outer)) + assertEquals(1, plan.preview.nestedArchives) + assertEquals(1, plan.preview.filesSkipped) + assertEquals(1, plan.preview.notes) + } + + @Test + fun invalidUtf8NotesAreReportedAndTheRestImports() { + val plan = ImportFixtures.ready( + ImportFixtures.source("bad.md" to byteArrayOf(0xc3.toByte(), 0x28), "good.md" to "fine".toByteArray()), + ) + assertEquals(1, plan.preview.notes) + assertEquals(1, plan.preview.filesSkipped) + } + + @Test + fun nothingReadBecauseOfTheLimitsOrUnreadableFilesIsSaidSo() { + val tooBig = ImportLimits(maxEntryBytes = 10, maxContainerBytes = 10) + val big = ImportFixtures.read(ImportFixtures.textSource("a.md" to "x".repeat(50)), tooBig) + assertEquals(Analysis.Failed(ImportError.TOO_LARGE), ImportAnalyzer.analyze(big)) + + val broken = ImportFileSource { sequenceOf(SourceFile("a.md") { throw java.io.IOException("x") }) } + assertEquals(Analysis.Failed(ImportError.UNREADABLE), ImportAnalyzer.analyze(ImportFixtures.read(broken))) + + val none = ImportFixtures.read(ImportFixtures.textSource()) + assertEquals(Analysis.Failed(ImportError.NOTHING_FOUND), ImportAnalyzer.analyze(none)) + } +} diff --git a/app/src/test/java/com/pasich/encly/data/importer/ImportReaderTest.kt b/app/src/test/java/com/pasich/encly/data/importer/ImportReaderTest.kt new file mode 100644 index 00000000..a041b403 --- /dev/null +++ b/app/src/test/java/com/pasich/encly/data/importer/ImportReaderTest.kt @@ -0,0 +1,217 @@ +package com.pasich.encly.data.importer + +import com.pasich.encly.data.importer.ImportFixtures.read +import com.pasich.encly.data.importer.ImportFixtures.source +import com.pasich.encly.data.importer.ImportFixtures.tar +import com.pasich.encly.data.importer.ImportFixtures.textSource +import com.pasich.encly.data.importer.ImportFixtures.zip +import com.pasich.encly.data.importer.ImportFixtures.zipText +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.ByteArrayInputStream +import java.io.IOException + +class ImportReaderTest { + + @Test + fun readsPlainFilesZipAndTarMembersWithTheirPaths() { + val result = read( + source( + "a.md" to "A".toByteArray(), + "pack.zip" to zipText("dir/b.md" to "B", "dir/sub/c.txt" to "C"), + "pack.tar" to tar(TarFile("./d.md", "D".toByteArray()), TarFile("sub/", type = '5')), + ), + ) + assertEquals(listOf("a.md", "dir/b.md", "dir/sub/c.txt", "d.md"), result.entries.map { it.path }) + assertEquals("D", String(result.entries.last().bytes)) + assertEquals(0, result.skips.total) + } + + @Test + fun aGnuLongNameIsUsed() { + val longName = "folder/" + "x".repeat(150) + ".md" + val result = read(source("p.tar" to tar(TarFile(longName, "T".toByteArray())))) + assertEquals(listOf(longName), result.entries.map { it.path }) + } + + @Test + fun unsafePathsAreSkippedAndCounted() { + val names = listOf("../evil.md", "/abs.md", "a/../../b.md", "back\\slash.md", "nul\u0000.md", "C:/x.md") + val result = read(source("p.zip" to zip(*(names + "ok.md").map { it to ByteArray(1) }.toTypedArray()))) + assertEquals(listOf("ok.md"), result.entries.map { it.path }) + assertEquals(names.size, result.skips.unsafePaths) + } + + @Test + fun aPathDeeperThanTheLimitIsUnsafe() { + val deep = (1..ImportLimits.MAX_PATH_DEPTH + 1).joinToString("/") { "d$it" } + "/n.md" + val ok = (1..ImportLimits.MAX_PATH_DEPTH - 1).joinToString("/") { "d$it" } + "/n.md" + val result = read(source("p.zip" to zip(deep to ByteArray(1), ok to ByteArray(1)))) + assertEquals(listOf(ok), result.entries.map { it.path }) + assertEquals(1, result.skips.unsafePaths) + } + + @Test + fun tarLinksAndDevicesAreNeverFollowed() { + val result = read( + source( + "p.tar" to tar( + TarFile("link.md", type = '2'), + TarFile("hard.md", type = '1'), + TarFile("dev", type = '3'), + TarFile("ok.md", "x".toByteArray()), + ), + ), + ) + assertEquals(listOf("ok.md"), result.entries.map { it.path }) + assertEquals(3, result.skips.unsafePaths) + } + + @Test + fun anArchiveInsideAnArchiveIsNotExpanded() { + val inner = zipText("deep.md" to "deep") + val result = read(source("outer.zip" to zip("inner.zip" to inner, "keep.md" to "k".toByteArray()))) + assertEquals(listOf("keep.md"), result.entries.map { it.path }) + assertEquals(1, result.skips.nestedArchives) + } + + @Test + fun aZipBombStopsAtTheTotalLimit() { + // 1 MiB of zeros compresses to about a kilobyte; ten of them exceed a 4 MiB total. + val bomb = ByteArray(1024 * 1024) + val files = (1..10).map { "f$it.md" to bomb }.toTypedArray() + val limits = ImportLimits(maxTotalBytes = 4L * 1024 * 1024) + val error = assertThrows(ImportException::class.java) { read(source("bomb.zip" to zip(*files)), limits) } + assertEquals(ImportError.TOO_LARGE, error.error) + } + + @Test + fun anEntryOverThePerEntryLimitIsSkippedButStillCounted() { + val big = ByteArray(2048) + val limits = ImportLimits(maxEntryBytes = 1024) + val result = read(source("p.zip" to zip("big.md" to big, "small.md" to ByteArray(10))), limits) + assertEquals(listOf("small.md"), result.entries.map { it.path }) + assertEquals(1, result.skips.tooLarge) + } + + @Test + fun aSingleEntryBombHitsTheTotalLimitEvenIfItIsSkipped() { + val limits = ImportLimits(maxEntryBytes = 1024, maxTotalBytes = 64 * 1024) + val error = assertThrows(ImportException::class.java) { + read(source("p.zip" to zip("huge.md" to ByteArray(1024 * 1024))), limits) + } + assertEquals(ImportError.TOO_LARGE, error.error) + } + + @Test + fun theInputByteLimitCountsWhatIsReallyRead() { + val limits = ImportLimits(maxInputBytes = 1000) + val noise = java.util.Random(1).let { r -> ByteArray(5000).also(r::nextBytes) } + val error = assertThrows(ImportException::class.java) { read(source("n.md" to noise), limits) } + assertEquals(ImportError.TOO_LARGE, error.error) + } + + @Test + fun tooManyEntriesIsTooLarge() { + val limits = ImportLimits(maxEntries = 5) + val files = (1..6).map { "n$it.md" to ByteArray(1) }.toTypedArray() + val error = assertThrows(ImportException::class.java) { read(source("p.zip" to zip(*files)), limits) } + assertEquals(ImportError.TOO_LARGE, error.error) + } + + @Test + fun aJsonExportMayBeLargerThanANoteFile() { + val json = ByteArray(2048) + val limits = ImportLimits(maxEntryBytes = 1024, maxContainerBytes = 4096) + val result = read(source("backup.json" to json, "note.md" to json), limits) + assertEquals(listOf("backup.json"), result.entries.map { it.path }) + assertEquals(1, result.skips.tooLarge) + } + + @Test + fun anUnreadableFileIsCountedNotFatal() { + val broken = ImportFileSource { + sequenceOf( + SourceFile("gone.md") { null }, + SourceFile("io.md") { throw IOException("x") }, + SourceFile("denied.md") { throw SecurityException("x") }, + SourceFile("ok.md") { ByteArrayInputStream("ok".toByteArray()) }, + ) + } + val result = read(broken) + assertEquals(listOf("ok.md"), result.entries.map { it.path }) + assertEquals(3, result.skips.unreadable) + } + + @Test + fun aDamagedZipKeepsWhatWasReadAndIsCounted() { + val good = zipText("a.md" to "A", "b.md" to "B") + val cut = good.copyOf(good.size / 2) + val result = read(source("p.zip" to cut)) + assertTrue(result.skips.unreadable >= 1 || result.entries.isNotEmpty()) + } + + @Test + fun aGzipFileIsNotOpened() { + val result = read(source("a.gz" to byteArrayOf(0x1f, 0x8b.toByte(), 8, 0, 0))) + assertTrue(result.entries.isEmpty()) + assertEquals(1, result.skips.nestedArchives) + } + + @Test + fun cancellationStopsTheRead() { + val check = { throw IllegalStateException("stop") } + assertThrows(IllegalStateException::class.java) { + ImportReader().read(textSource("a.md" to "x"), check) + } + } + + @Test + fun safePathNormalizes() { + assertEquals("a/b.md", ImportReader.safePath("./a//b.md")) + assertNull(ImportReader.safePath("..")) + assertNull(ImportReader.safePath("")) + assertNull(ImportReader.safePath("a/../b")) + } + + @Test + fun aDirectoryEntryWithDataIsSkippedAsUnsafe() { + val result = read(source("bomb.zip" to directoryZip(count = 1, bytesEach = 8 * 1024 * 1024))) + assertTrue(result.entries.isEmpty()) + assertEquals(1, result.skips.unsafePaths) + } + + @Test + fun manyDirectoriesWithDataHitTheTotalLimit() { + // Each directory inflates 1 MiB of zeros; together they must count against the total. + val limits = ImportLimits(maxTotalBytes = 4L * 1024 * 1024) + val error = assertThrows(ImportException::class.java) { + read(source("bomb.zip" to directoryZip(count = 10, bytesEach = 1024 * 1024)), limits) + } + assertEquals(ImportError.TOO_LARGE, error.error) + } + + @Test + fun directoryEntriesCountAgainstTheEntryLimit() { + val limits = ImportLimits(maxEntries = 5) + val error = assertThrows(ImportException::class.java) { + read(source("dirs.zip" to directoryZip(count = 6, bytesEach = 0)), limits) + } + assertEquals(ImportError.TOO_LARGE, error.error) + } + + private fun directoryZip(count: Int, bytesEach: Int): ByteArray { + val out = java.io.ByteArrayOutputStream() + java.util.zip.ZipOutputStream(out).use { zip -> + repeat(count) { + zip.putNextEntry(java.util.zip.ZipEntry("d$it/")) + if (bytesEach > 0) zip.write(ByteArray(bytesEach)) + zip.closeEntry() + } + } + return out.toByteArray() + } +} diff --git a/app/src/test/java/com/pasich/encly/data/importer/KeepParserTest.kt b/app/src/test/java/com/pasich/encly/data/importer/KeepParserTest.kt new file mode 100644 index 00000000..69c8b46d --- /dev/null +++ b/app/src/test/java/com/pasich/encly/data/importer/KeepParserTest.kt @@ -0,0 +1,125 @@ +package com.pasich.encly.data.importer + +import com.pasich.encly.data.importer.ImportFixtures.describe +import com.pasich.encly.data.importer.ImportFixtures.entry +import com.pasich.encly.data.importer.ImportFixtures.parse +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class KeepParserTest { + private fun keep(extra: String, created: Long = 1_700_000_000_123_456, edited: Long = 1_700_000_100_000_000) = + """{"createdTimestampUsec":$created,"userEditedTimestampUsec":$edited,$extra}""" + + private fun one(json: String): ImportedNote = + parse(ImportSource.GOOGLE_KEEP, entry("Takeout/Keep/n.json", json)).notes.single() + + @Test + fun mapsTextTitleLabelAndMicrosecondTimestamps() { + val note = + one(keep(""""title":"Plan","textContent":"first\n\nsecond line\nthird","labels":[{"name":"Work"}]""")) + assertEquals("Plan", note.title) + assertEquals(listOf("text:first", "text:second line\nthird"), describe(note.blocks)) + assertEquals(listOf("Work"), note.tags) + assertEquals(1_700_000_000_123L, note.created) + assertEquals(1_700_000_100_000L, note.edited) + assertFalse(note.trashed) + assertEquals("n.json|1700000000123456", note.sourceKey) + } + + @Test + fun aChecklistKeepsItsItemsAndStateEvenWithoutText() { + val note = one( + keep( + """"title":"Shop","listContent":[{"text":"Milk","isChecked":true},""" + + """{"text":"Eggs","isChecked":false},{"text":" ","isChecked":false}]""", + ), + ) + assertEquals(listOf("check:[x] Milk|[ ] Eggs"), describe(note.blocks)) + } + + @Test + fun textAndChecklistTogether() { + val note = one(keep(""""textContent":"intro","listContent":[{"text":"a","isChecked":false}]""")) + assertEquals(listOf("text:intro", "check:[ ] a"), describe(note.blocks)) + } + + @Test + fun trashedPinnedArchivedColourAttachmentsAndExtraLabelsAreCounted() { + val note = one( + keep( + """"title":"x","textContent":"y","isTrashed":true,"isPinned":true,"isArchived":true,"color":"RED", + |"attachments":[{"filePath":"a.jpg"},{"filePath":"b.jpg"}], + |"labels":[{"name":"A"},{"name":"B"},{"name":"a"}] + """.trimMargin(), + ), + ) + assertTrue(note.trashed) + assertEquals(ImportLosses(attachments = 2, pinned = 1, archived = 1, colors = 1, extraTags = 1), note.losses) + assertEquals(listOf("A", "B"), note.tags) + } + + @Test + fun theDefaultColourIsNoLoss() { + assertEquals(ImportLosses(), one(keep(""""title":"x","color":"DEFAULT"""")).losses) + } + + @Test + fun badTimestampsAreNotImported() { + val negative = one(keep(""""title":"x"""", created = -5, edited = 0)) + assertNull(negative.created) + assertNull(negative.edited) + val overflow = one(keep(""""title":"x"""", created = Long.MAX_VALUE, edited = 9_000_000_000_000_000_000)) + assertNull(overflow.created) + assertNull(overflow.edited) + } + + @Test + fun anEmptyNoteIsSkippedAndCounted() { + val parsed = parse(ImportSource.GOOGLE_KEEP, entry("a.json", keep("\"title\":\"\",\"textContent\":\"\""))) + assertTrue(parsed.notes.isEmpty()) + assertEquals(1, parsed.skips.empty) + } + + @Test + fun htmlDuplicatesMediaAndLabelsFileAreIgnoredAndOtherJsonIsUnsupported() { + val parsed = parse( + ImportSource.GOOGLE_KEEP, + entry("Takeout/Keep/n.html", "

x

"), + entry("Takeout/Keep/Labels.txt", "A"), + entry("Takeout/Keep/n.json", keep(""""title":"T"""")), + entry("Takeout/Keep/other.json", """{"a":1}"""), + ) + assertEquals(1, parsed.notes.size) + assertEquals(1, parsed.skips.unsupported) + } + + @Test + fun invalidUtf8IsSkippedAndReported() { + val parsed = parse( + ImportSource.GOOGLE_KEEP, + ImportEntry("bad.json", byteArrayOf(0x7b, 0xff.toByte(), 0x7d)), + entry("ok.json", keep(""""title":"T"""")), + ) + assertEquals(1, parsed.notes.size) + assertEquals(1, parsed.skips.unreadable) + } + + @Test + fun tooManyNotesIsTooLarge() { + val limits = ImportLimits(maxNotes = 2) + val entries = (1..3).map { entry("n$it.json", keep(""""title":"T$it"""")) } + val error = org.junit.Assert.assertThrows(ImportException::class.java) { KeepParser.parse(entries, limits) } + assertEquals(ImportError.TOO_LARGE, error.error) + } + + @Test + fun aVeryDeeplyNestedJsonIsNotParsed() { + val deep = "[".repeat(5000) + "]".repeat(5000) + val parsed = parse(ImportSource.GOOGLE_KEEP, entry("deep.json", deep)) + assertTrue(parsed.notes.isEmpty()) + assertEquals(1, parsed.skips.unsupported) + } +} diff --git a/app/src/test/java/com/pasich/encly/data/importer/MarkdownBlockMapperTest.kt b/app/src/test/java/com/pasich/encly/data/importer/MarkdownBlockMapperTest.kt new file mode 100644 index 00000000..5dbf557c --- /dev/null +++ b/app/src/test/java/com/pasich/encly/data/importer/MarkdownBlockMapperTest.kt @@ -0,0 +1,65 @@ +package com.pasich.encly.data.importer + +import com.pasich.encly.data.importer.ImportFixtures.describe +import org.junit.Assert.assertEquals +import org.junit.Test + +class MarkdownBlockMapperTest { + private fun blocks(markdown: String) = describe(MarkdownBlockMapper.toBlocks(markdown)) + + @Test + fun headingsAreH1ToH4AndDeeperOnesH4() { + assertEquals( + listOf("h1:A", "h2:B", "h3:C", "h4:D", "h4:E", "h4:F"), + blocks("# A\n## B\n### C\n#### D\n##### E\n###### F"), + ) + } + + @Test + fun listsAreFlatAndKeepTheirKind() { + assertEquals(listOf("check:[x] a|[ ] b|[x] c"), blocks("- [x] a\n* [ ] b\n+ [X] c")) + assertEquals(listOf("bullet:[ ] a|[ ] b|[ ] c"), blocks("- a\n* b\n+ c")) + assertEquals(listOf("number:[ ] one|[ ] two"), blocks("1. one\n2) two")) + assertEquals(listOf("bullet:[ ] a|[ ] nested|[ ] b"), blocks("- a\n - nested\n- b")) + } + + @Test + fun aLooseListStaysOneBlockAndAKindChangeStartsANewOne() { + assertEquals(listOf("bullet:[ ] a|[ ] b", "number:[ ] one"), blocks("- a\n\n- b\n1. one")) + } + + @Test + fun quotesRulesAndParagraphs() { + assertEquals( + listOf("text:first\nstill first", "quote:q1\nq2", "separator", "separator", "text:last"), + blocks("first\nstill first\n\n> q1\n> q2\n\n---\n***\n\nlast"), + ) + } + + @Test + fun anIndentedLineContinuesTheListItem() { + assertEquals(listOf("bullet:[ ] a b|[ ] c"), blocks("- a\n b\n- c")) + } + + @Test + fun codeIsKeptAsTextWithoutItsFences() { + assertEquals(listOf("text:val x = 1\nprint(x)"), blocks("```kotlin\nval x = 1\nprint(x)\n```")) + } + + @Test + fun htmlIsStrippedOutsideCodeSpansAndLinksSurvive() { + assertEquals( + listOf("text:bold and `code` a & b".replace("", "https://x.org")), + blocks("bold and `code` a & b"), + ) + } + + @Test + fun noTextIsDropped() { + assertEquals( + listOf("text:#hashtag no space", "text:| a | b |\n|---|---|"), + blocks("#hashtag no space\n\n| a | b |\n|---|---|"), + ) + assertEquals(emptyList(), blocks("\n\n \n")) + } +} diff --git a/app/src/test/java/com/pasich/encly/data/importer/NotesnookAndMarkdownFixturesTest.kt b/app/src/test/java/com/pasich/encly/data/importer/NotesnookAndMarkdownFixturesTest.kt new file mode 100644 index 00000000..bb8b7a4e --- /dev/null +++ b/app/src/test/java/com/pasich/encly/data/importer/NotesnookAndMarkdownFixturesTest.kt @@ -0,0 +1,202 @@ +package com.pasich.encly.data.importer + +import com.pasich.encly.core.serialization.BlockConverter +import com.pasich.encly.data.importer.ImportFixtures.describe +import com.pasich.encly.data.importer.ImportFixtures.source +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Test +import java.time.LocalDateTime +import java.time.ZoneId + +/** + * Real Notesnook exports (Markdown, Markdown with front matter, text, an encrypted backup) and an + * Obsidian vault on disk, read the way the app reads them. Skipped while a fixture is absent. + */ +class NotesnookAndMarkdownFixturesTest { + private fun local(day: Int, hour: Int, minute: Int) = LocalDateTime.of(2026, 10, day, hour, minute) + .atZone(ZoneId.systemDefault()).toInstant().toEpochMilli() + + private val meeting = listOf( + "h1:Weekly sync", + "h2:Agenda", + "bullet:[ ] Release 2.2|[ ] Translations", + "number:[ ] Review PRs|[ ] Ship", + "quote:Decisions are final.", + "separator", + "text:Plain closing paragraph.", + ) + private val groceries = listOf("check:[x] Milk|[ ] Eggs|[ ] Хліб 🍞") + private val ideas = listOf("text:Перша думка.", "text:Друга думка.") + + private fun plan(name: String, forced: ImportSource? = null): ImportPlan { + val zip = ImportFixtures.resource("notesnook/$name") + assertNotNull("real export fixture missing", zip) + return ImportFixtures.ready(source(name to zip!!), forced) + } + + @Test + fun notesnookMarkdownWithFrontMatter() { + val plan = plan("notes-md-frontmatter.zip") + assertEquals(ImportSource.NOTESNOOK, plan.preview.source) + checkPlan( + plan, + listOf( + ExpectedNote("Groceries", groceries, "home", local(14, 22, 47), local(14, 22, 48)), + ExpectedNote( + "Meeting notes", + meeting, + "work", + local(14, 22, 48), + local(14, 22, 48), + losses = ImportLosses(extraTags = 1), + ), + ExpectedNote("Ідеї 💡", ideas, null, local(14, 22, 49), local(14, 22, 49)), + ExpectedNote( + "Pinned reminder", + listOf("text:Water the plants."), + null, + local(14, 22, 50), + local(14, 22, 50), + losses = ImportLosses(pinned = 1), + ), + ExpectedNote( + "Archived recipe", + listOf("text:Flour, water, salt."), + null, + local(14, 22, 49), + local(14, 22, 49), + losses = ImportLosses(archived = 1), + ), + ), + ) + } + + private fun withoutFrontMatter(name: String, meetingBlocks: List, forced: ImportSource? = null) { + val plan = plan(name, forced) + checkPlan( + plan, + listOf( + ExpectedNote("Groceries", groceries, null, ImportFixtures.NOW, ImportFixtures.NOW), + ExpectedNote("Meeting notes", meetingBlocks, "Work", ImportFixtures.NOW, ImportFixtures.NOW), + ExpectedNote("Ідеї 💡", ideas, null, ImportFixtures.NOW, ImportFixtures.NOW), + ExpectedNote( + "Pinned reminder", + listOf("text:Water the plants."), + null, + ImportFixtures.NOW, + ImportFixtures.NOW, + ), + ExpectedNote( + "Archived recipe", + listOf("text:Flour, water, salt."), + null, + ImportFixtures.NOW, + ImportFixtures.NOW, + losses = ImportLosses(archived = 1), + ), + ), + ) + } + + @Test + fun notesnookPlainMarkdownIsReadAsMarkdownWithTheTitleFromItsFirstHeading() { + withoutFrontMatter("notes-md.zip", meeting) + assertEquals(ImportSource.MARKDOWN, plan("notes-md.zip").preview.source) + } + + @Test + fun notesnookPlainMarkdownChosenManuallyAsNotesnookGivesTheSameNotes() { + withoutFrontMatter("notes-md.zip", meeting, forced = ImportSource.NOTESNOOK) + } + + @Test + fun notesnookTextExport() { + val txtMeeting = listOf( + "text:WEEKLY SYNC", + "text:AGENDA", + "bullet:[ ] Release 2.2|[ ] Translations", + "number:[ ] Review PRs|[ ] Ship", + "quote:Decisions are final.", + "separator", + "text:Plain closing paragraph.", + ) + withoutFrontMatter("notes-txt.zip", txtMeeting) + } + + @Test + fun anEncryptedNotesnookBackupIsRefusedWithGuidance() { + val zip = ImportFixtures.resource("notesnook/backup.nnbackupz") + assertNotNull("real export fixture missing", zip) + assertEquals( + Analysis.Encrypted(ImportSource.NOTESNOOK), + ImportFixtures.analyze(source("backup.nnbackupz" to zip!!)), + ) + // Choosing a source by hand does not make it readable. + assertEquals( + Analysis.Encrypted(ImportSource.NOTESNOOK), + ImportFixtures.analyze(source("backup.nnbackupz" to zip), ImportSource.NOTESNOOK), + ) + } + + @Test + fun anObsidianVaultFolder() { + val tree = ImportFixtures.resourceTree("markdown/obsidian-vault") + assertNotNull("real export fixture missing", tree) + val plan = ImportFixtures.ready(tree!!) + + assertEquals(ImportSource.MARKDOWN, plan.preview.source) + // .obsidian/ is skipped whole, the image is counted as an attachment, nothing is unsupported. + assertEquals(0, plan.preview.unsupported + plan.preview.filesSkipped) + checkPlan( + plan, + listOf( + ExpectedNote("Groceries", groceries, "home", local(14, 22, 30), local(14, 22, 30)), + ExpectedNote( + "Meeting notes", + listOf( + "h1:Weekly sync", + "h2:Agenda", + "bullet:[ ] Release 2.2|[ ] Translations|[ ] nested detail", + "number:[ ] Review PRs|[ ] Ship", + "quote:Decisions\nDecisions are final.", + "separator", + "text:Linked to Ідеї 💡 and the shopping list. #followup", + ), + "work", + ImportFixtures.NOW, + ImportFixtures.NOW, + losses = ImportLosses(extraTags = 1), + ), + ExpectedNote( + "Ідеї 💡", + listOf("text:Перша думка.", "text:Друга думка з **жирним** і `кодом`."), + null, + ImportFixtures.NOW, + ImportFixtures.NOW, + ), + ExpectedNote( + "Markor note", + listOf( + "text:Plain text note from Markor.\nSecond line of the same paragraph.", + "text:New paragraph.", + ), + null, + ImportFixtures.NOW, + ImportFixtures.NOW, + ), + ), + attachments = 1, + ) + } + + @Test + fun theVaultFoldersTextIsStoredAsEnclyBlocksThatReadBack() { + val tree = ImportFixtures.resourceTree("markdown/obsidian-vault") + assertNotNull("real export fixture missing", tree) + val plan = ImportFixtures.ready(tree!!) + plan.payload.notes.forEach { + assertEquals(it.title, describe(BlockConverter.jsonToBlocks(it.value)).isNotEmpty(), true) + } + } +} diff --git a/app/src/test/java/com/pasich/encly/data/importer/RealExportsTest.kt b/app/src/test/java/com/pasich/encly/data/importer/RealExportsTest.kt new file mode 100644 index 00000000..44327bb2 --- /dev/null +++ b/app/src/test/java/com/pasich/encly/data/importer/RealExportsTest.kt @@ -0,0 +1,271 @@ +package com.pasich.encly.data.importer + +import com.pasich.encly.core.serialization.BlockConverter +import com.pasich.encly.data.importer.ImportFixtures.describe +import com.pasich.encly.data.importer.ImportFixtures.source +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Test +import java.time.Instant + +/** + * The parsers against exports produced by the real apps (src/test/resources/import-fixtures, + * synthetic notes). Each test is skipped, not failed, while its fixture is absent. + */ +class RealExportsTest { + private fun ms(iso: String) = Instant.parse(iso).toEpochMilli() + + // Google Keep: Takeout ZIP ------------------------------------------------------------- + + private val keepExpected: List by lazy { + val prefix = "Encly test · " + listOf( + ExpectedNote( + prefix + "Ідеї 💡", + listOf("text:Перша думка.", "text:Друга думка."), + "encly-test", + 1792006880674, + 1792006922502, + ), + ExpectedNote( + prefix + "Groceries", + listOf("check:[ ] Eggs|[ ] Хліб 🍞|[x] Milk"), + "encly-home", + 1792006977756, + 1792006990486, + losses = ImportLosses(extraTags = 1), + ), + ExpectedNote( + prefix + "Pinned reminder", + listOf("text:Water the plants."), + "encly-test", + 1792007023776, + 1792007030520, + losses = ImportLosses(pinned = 1), + ), + ExpectedNote( + prefix + "Archived recipe", + listOf("text:Flour, water, salt."), + "encly-test", + 1792007046736, + 1792007049509, + losses = ImportLosses(archived = 1), + ), + ExpectedNote( + prefix + "Old draft", + listOf("text:Delete me."), + "encly-test", + 1792007063708, + 1792007063708, + trashed = true, + ), + ) + } + + @Test + fun googleKeepTakeoutZip() { + val zip = ImportFixtures.resource("keep/takeout-keep.zip") + assertNotNull("real export fixture missing", zip) + val plan = ImportFixtures.ready(source("takeout-keep.zip" to zip!!)) + assertEquals(ImportSource.GOOGLE_KEEP, plan.preview.source) + assertEquals(5, plan.preview.notes) + assertEquals(1, plan.preview.checklists) + assertEquals(1, plan.preview.trashed) + // Labels.txt lists only the labels of the notes; a label no note uses is never created. + assertEquals( + listOf("encly-home", "encly-test"), + plan.payload.tags.map { it.name }.sorted().filter { + it == + "encly-home" || + it == "encly-test" + }, + ) + checkPlan(plan, keepExpected) + } + + @Test + fun googleKeepLooseJsonFilesAreTheSameAsTheZip() { + val zip = ImportFixtures.resource("keep/takeout-keep.zip") + assertNotNull("real export fixture missing", zip) + val fromZip = ImportFixtures.ready(source("takeout-keep.zip" to zip!!)) + val entries = ImportFixtures.read(source("takeout-keep.zip" to zip)).entries.filter { it.extension == "json" } + val loose = ImportFixtures.ready(source(*entries.map { it.name to it.bytes }.toTypedArray())) + assertEquals(fromZip.payload.notes.map { it.title }.sorted(), loose.payload.notes.map { it.title }.sorted()) + // The same notes are the same notes whichever way they were picked: a second import adds nothing. + assertEquals(fromZip.payload.notes.map { it.uid }.toSet(), loose.payload.notes.map { it.uid }.toSet()) + } + + // Standard Notes: decrypted backup ZIP ------------------------------------------------- + + @Test + fun standardNotesDecryptedBackupZip() { + val zip = ImportFixtures.resource("standard-notes/Standard Notes Backup - decrypted.zip") + assertNotNull("real export fixture missing", zip) + val plan = ImportFixtures.ready(source("backup.zip" to zip!!)) + assertEquals(ImportSource.STANDARD_NOTES, plan.preview.source) + assertEquals(6, plan.preview.notes) + // Only tags a note uses are created (the third has to be unused by the single-tag rule). + assertEquals(2, plan.payload.tags.size) + assertEquals(1, plan.preview.trashed) + assertEquals(1, plan.preview.losses.pinned) + assertEquals(1, plan.preview.losses.archived) + // Offline exports have updated_at = 1970: the edit time is the client's. + val groceries = plan.payload.notes.first { it.title == "Groceries" } + assertEquals(ms("2026-10-14T19:37:36.178Z"), groceries.date) + assertEquals(ms("2026-10-14T19:36:52.378Z"), groceries.dateCreate) + assertEquals( + listOf("check:[x] Milk|[ ] Eggs|[ ] Хліб 🍞"), + describe(BlockConverter.jsonToBlocks(groceries.value)), + ) + val meeting = plan.payload.notes.first { it.title == "Meeting notes" } + assertEquals( + listOf( + "h1:Weekly sync", + "h2:Agenda", + "bullet:[ ] Release 2.2|[ ] Translations", + "number:[ ] Review PRs|[ ] Ship", + "quote:Decisions are final.", + "separator", + "text:Plain closing paragraph.", + ), + describe(BlockConverter.jsonToBlocks(meeting.value)), + ) + val tagNames = plan.payload.tags.associate { it.uid to it.name } + assertEquals("home", tagNames[groceries.tagUid]) + assertEquals("important", tagNames[meeting.tagUid]) + val ideas = plan.payload.notes.first { it.title == "Ідеї 💡" } + assertEquals( + listOf("text:Перша думка.", "text:Друга думка."), + describe(BlockConverter.jsonToBlocks(ideas.value)), + ) + assertEquals(true, plan.payload.notes.first { it.title == "Old draft" }.isTrash) + // The Items/ copies are not imported a second time. + assertEquals(6, plan.payload.notes.size) + } + + // Joplin --------------------------------------------------------------------------------- + + private val joplinExpected: List by lazy { + fun t(s: String) = ms(s) + listOf( + ExpectedNote( + "Groceries", + listOf("check:[x] Milk|[ ] Eggs|[ ] Хліб 🍞"), + "home", + t("2026-10-14T19:32:48.892Z"), + t("2026-10-14T19:32:48.892Z"), + ), + ExpectedNote( + "Ідеї 💡", + listOf("text:Перша думка.", "text:Друга думка з **жирним** текстом."), + "Personal", + t("2026-10-14T19:32:50.617Z"), + t("2026-10-14T19:32:50.617Z"), + ), + ExpectedNote( + "Meeting notes", + listOf( + "h1:Weekly sync", + "h2:Agenda", + "bullet:[ ] Release 2.2|[ ] Translations", + "number:[ ] Review PRs|[ ] Ship", + "quote:Decisions are final.", + "separator", + "text:Plain closing paragraph.", + ), + "important", + t("2026-10-14T19:32:53.807Z"), + t("2026-10-14T19:32:53.807Z"), + losses = ImportLosses(extraTags = 1), + ), + ExpectedNote( + "Call the bank", + emptyList(), + "Work", + t("2026-10-14T19:32:55.989Z"), + t("2026-10-14T19:32:56.506Z"), + losses = ImportLosses(todos = 1), + ), + ExpectedNote( + "Renew passport", + emptyList(), + "Work", + t("2026-10-14T19:32:57.013Z"), + t("2026-10-14T19:32:57.013Z"), + losses = ImportLosses(todos = 1), + ), + ) + } + + private fun joplinPlan(source: ImportFileSource): ImportPlan { + val plan = ImportFixtures.ready(source) + assertEquals(5, plan.preview.notes) + return plan + } + + @Test + fun joplinJexArchive() { + val jex = ImportFixtures.resource("joplin/notes.jex") + assertNotNull("real export fixture missing", jex) + val plan = joplinPlan(source("notes.jex" to jex!!)) + assertEquals(ImportSource.JOPLIN, plan.preview.source) + checkPlan(plan, joplinExpected) + assertEquals(0, plan.preview.unsupported) + } + + @Test + fun joplinRawFolder() { + val tree = ImportFixtures.resourceTree("joplin/raw") + assertNotNull("real export fixture missing", tree) + val plan = joplinPlan(tree!!) + assertEquals(ImportSource.JOPLIN, plan.preview.source) + checkPlan(plan, joplinExpected) + // Same items in the JEX and in the folder: the same uids, so one import covers both. + val jex = ImportFixtures.resource("joplin/notes.jex") + assertNotNull("real export fixture missing", jex) + val fromJex = joplinPlan(source("notes.jex" to jex!!)) + assertEquals(fromJex.payload.notes.map { it.uid }.toSet(), plan.payload.notes.map { it.uid }.toSet()) + } + + @Test + fun joplinMarkdownExportIsGenericMarkdown() { + val tree = ImportFixtures.resourceTree("joplin/md") + assertNotNull("real export fixture missing", tree) + val plan = joplinPlan(tree!!) + assertEquals(ImportSource.MARKDOWN, plan.preview.source) + // The Markdown export has no front matter: the file name is the title, the folder the tag. + val titles = plan.payload.notes.map { it.title }.sorted() + assertEquals( + listOf("Call the bank", "Groceries", "Meeting notes", "Renew passport", "Ідеї 💡").sorted(), + titles, + ) + val groceries = plan.payload.notes.first { it.title == "Groceries" } + assertEquals( + listOf("check:[x] Milk|[ ] Eggs|[ ] Хліб 🍞"), + describe(BlockConverter.jsonToBlocks(groceries.value)), + ) + assertEquals(setOf("Personal", "Work"), plan.payload.tags.map { it.name }.toSet()) + } + + @Test + fun joplinMarkdownWithFrontMatterKeepsTagsAndDates() { + val tree = ImportFixtures.resourceTree("joplin/md_frontmatter") + assertNotNull("real export fixture missing", tree) + val plan = joplinPlan(tree!!) + assertEquals(ImportSource.MARKDOWN, plan.preview.source) + val tagNames = plan.payload.tags.associate { it.uid to it.name } + val groceries = plan.payload.notes.first { it.title == "Groceries" } + assertEquals("home", tagNames[groceries.tagUid]) + assertEquals(ms("2026-10-14T19:32:48Z"), groceries.dateCreate) + val meeting = plan.payload.notes.first { it.title == "Meeting notes" } + assertEquals("important", tagNames[meeting.tagUid]) + assertEquals(ms("2026-10-14T19:32:53Z"), meeting.date) + val ideas = plan.payload.notes.first { it.title == "Ідеї 💡" } + assertEquals( + listOf("text:Перша думка.", "text:Друга думка з **жирним** текстом."), + describe(BlockConverter.jsonToBlocks(ideas.value)), + ) + // Notes without tags get their folder as the tag. + assertEquals("Work", tagNames[plan.payload.notes.first { it.title == "Call the bank" }.tagUid]) + } +} diff --git a/app/src/test/java/com/pasich/encly/data/importer/TextParsersTest.kt b/app/src/test/java/com/pasich/encly/data/importer/TextParsersTest.kt new file mode 100644 index 00000000..0d6d31bd --- /dev/null +++ b/app/src/test/java/com/pasich/encly/data/importer/TextParsersTest.kt @@ -0,0 +1,309 @@ +package com.pasich.encly.data.importer + +import com.pasich.encly.data.importer.ImportFixtures.describe +import com.pasich.encly.data.importer.ImportFixtures.entry +import com.pasich.encly.data.importer.ImportFixtures.parse +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class StandardNotesParserTest { + private fun backup(vararg items: String, extra: String = "") = + """{"version":"004","items":[${items.joinToString(",")}]$extra}""" + + private fun note( + uuid: String, + content: String, + created: String = "2024-05-01T10:00:00.000Z", + updated: String = "1970-01-01T00:00:00.000Z", + ) = """{"uuid":"$uuid","content_type":"Note","created_at":"$created","updated_at":"$updated",""" + + """"content":{$content}}""" + + private fun tag(title: String, vararg notes: String) = + """{"uuid":"t-$title","content_type":"Tag","content":{"title":"$title","references":[${notes.joinToString(",") { + """{"uuid":"$it","content_type":"Note"}""" + }}]}}""" + + private fun run(json: String) = + parse(ImportSource.STANDARD_NOTES, entry("Standard Notes Backup and Import File.txt", json)) + + @Test + fun mapsNotesTagsTrashAndTheClientEditTime() { + val parsed = run( + backup( + note( + "n1", + """"title":"Plan","text":"- [x] a\n- [ ] b","noteType":"plain-text","trashed":true,""" + + """"appData":{"org.standardnotes.sn":{"client_updated_at":"2024-05-02T11:00:00.000Z",""" + + """"pinned":true,"archived":true}}""", + ), + tag("work", "n1"), + tag("home", "n1"), + """{"uuid":"p","content_type":"SN|UserPreferences","content":{}}""", + ), + ) + val n = parsed.notes.single() + assertEquals("n1", n.sourceKey) + assertEquals("Plan", n.title) + assertEquals(listOf("check:[x] a|[ ] b"), describe(n.blocks)) + assertEquals(listOf("home", "work"), n.tags) + assertTrue(n.trashed) + assertEquals(1_714_647_600_000L, n.edited) + assertEquals(1_714_557_600_000L, n.created) + assertEquals(ImportLosses(pinned = 1, archived = 1, extraTags = 1), n.losses) + } + + @Test + fun anEpochZeroUpdateIsNeverImported() { + val n = run(backup(note("n", """"title":"x","text":"y""""))).notes.single() + assertNull(n.edited) + } + + @Test + fun readsPinnedAndArchivedFromTheTopLevelAsAFallback() { + val n = run(backup(note("n", """"title":"x","text":"y","pinned":true"""))).notes.single() + assertEquals(1, n.losses.pinned) + } + + @Test + fun superNotesAreReadFromTheirLexicalText() { + val lexical = """{"root":{"type":"root","children":[ + {"type":"heading","tag":"h2","children":[{"type":"text","text":"Head"}]}, + {"type":"paragraph","children":[{"type":"text","text":"one "},{"type":"linebreak"}, + {"type":"text","text":"two"}]}, + {"type":"list","listType":"check","children":[ + {"type":"listitem","checked":true,"children":[{"type":"text","text":"done"}]}, + {"type":"listitem","checked":false,"children":[{"type":"text","text":"todo"}]}]}, + {"type":"list","listType":"bullet","children":[ + {"type":"listitem","children":[{"type":"text","text":"a"}]}, + {"type":"listitem","children":[{"type":"list","listType":"bullet","children":[ + {"type":"listitem","children":[{"type":"text","text":"nested"}]}]}]}]}, + {"type":"quote","children":[{"type":"text","text":"q"}]}, + {"type":"horizontalrule"}]}}""" + val text = lexical.replace("\n", "").replace("\"", "\\\"") + val n = run(backup(note("s", """"title":"Super","text":"$text","noteType":"super""""))).notes.single() + assertEquals( + listOf( + "h2:Head", + "text:one \ntwo", + "check:[x] done|[ ] todo", + "bullet:[ ] a|[ ] nested", + "quote:q", + "separator", + ), + describe(n.blocks), + ) + } + + @Test + fun aSuperNoteThatIsNotLexicalFallsBackToItsPreviewThenItsText() { + val withPreview = run( + backup(note("s", """"title":"a","text":"{broken","noteType":"super","preview_plain":"preview"""")), + ).notes.single() + assertEquals(listOf("text:preview"), describe(withPreview.blocks)) + val plain = run(backup(note("s", """"title":"a","text":"raw text","noteType":"super""""))).notes.single() + assertEquals(listOf("text:raw text"), describe(plain.blocks)) + } + + @Test + fun otherTypesAreParagraphsAndRichTextLosesItsHtml() { + val code = run(backup(note("c", """"title":"c","text":"a\n\nb","noteType":"code""""))).notes.single() + assertEquals(listOf("text:a", "text:b"), describe(code.blocks)) + val rich = run( + backup(note("r", """"title":"r","text":"

Hi there

","noteType":"rich-text"""")), + ).notes.single() + assertEquals(listOf("text:Hi there"), describe(rich.blocks)) + } + + @Test + fun markdownNotesGetBlocks() { + val n = run(backup(note("m", """"title":"m","text":"# H\n\n1. a","noteType":"markdown""""))).notes.single() + assertEquals(listOf("h1:H", "number:[ ] a"), describe(n.blocks)) + } + + @Test + fun anEncryptedBackupIsNeverRead() { + val parsed = + run( + backup( + note("n", """"title":"x"""").replace(""""content":{""", """"content_x":{"""), + extra = ""","keyParams":{}""", + ), + ) + assertTrue(parsed.notes.isEmpty()) + assertEquals(1, parsed.skips.unsupported) + } +} + +class JoplinParserTest { + private val folder = "0123456789abcdef0123456789abcdef" + private val noteId = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + private val tagId = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + + private fun meta(id: String, type: Int, vararg more: String) = + (listOf("id: $id") + more + "type_: $type").joinToString("\n") + + @Test + fun parsesTheMetadataBlockFromTheBottom() { + val item = JoplinParser.item( + "Title\n\nbody line\n\nsecond paragraph\n\n" + + meta(noteId, 1, "parent_id: $folder", "latitude: 0.0", "user_data: "), + )!! + assertEquals("Title", item.title) + assertEquals("body line\n\nsecond paragraph", item.body) + assertEquals(noteId, item.id) + assertEquals("", item.props["user_data"]) + assertEquals(1, item.type) + } + + @Test + fun aNoteWithAnEmptyBodyAndALinkWithNoHead() { + assertEquals("", JoplinParser.item("Title\n\n\n\n" + meta(noteId, 1))!!.body) + val link = JoplinParser.item(meta("c".repeat(32), 6, "note_id: $noteId", "tag_id: $tagId"))!! + assertEquals(6, link.type) + assertEquals(tagId, link.props["tag_id"]) + } + + @Test + fun textWithoutAValidBlockIsNotJoplin() { + assertNull(JoplinParser.item("Title\n\nbody")) + assertNull(JoplinParser.item("Title\n\nid: short\ntype_: 1")) + assertNull(JoplinParser.item("Title\n\nid: $noteId\ntype_: x")) + assertNull(JoplinParser.item("Title\n\nid: $noteId\nnot a property\ntype_: 1")) + } + + @Test + fun linksTagsAndNotebooksBecomeTheNotesTag() { + val parsed = ImportFixtures.parse( + ImportSource.JOPLIN, + ImportFixtures.entry("f.md", "Personal\n\n" + meta(folder, 2)), + ImportFixtures.entry("t1.md", "zeta\n\n" + meta(tagId, 5)), + ImportFixtures.entry("t2.md", "alpha\n\n" + meta("d".repeat(32), 5)), + ImportFixtures.entry("l1.md", meta("e".repeat(32), 6, "note_id: $noteId", "tag_id: $tagId")), + ImportFixtures.entry("l2.md", meta("f".repeat(32), 6, "note_id: $noteId", "tag_id: ${"d".repeat(32)}")), + ImportFixtures.entry( + "n.md", + "Shopping\n\n- [x] milk\n\n" + meta( + noteId, + 1, + "parent_id: $folder", + "created_time: 2024-01-02T03:04:05.678Z", + "updated_time: 2024-01-03T00:00:00.000Z", + "user_updated_time: 2024-01-04T00:00:00.000Z", + "is_todo: 1", + "deleted_time: 1704067200000", + ), + ), + ImportFixtures.entry( + "n2.md", + "Plain\n\nbody\n\n" + meta("1".repeat(32), 1, "parent_id: $folder", "deleted_time: 0"), + ), + ImportFixtures.entry("r.md", "pic.png\n\n" + meta("2".repeat(32), 4)), + ImportFixtures.entry("readme.md", "just a readme"), + ) + assertEquals(2, parsed.notes.size) + val shopping = parsed.notes.first { it.title == "Shopping" } + assertEquals(listOf("alpha", "zeta"), shopping.tags) + assertEquals(1, shopping.losses.extraTags) + assertEquals(1, shopping.losses.todos) + assertTrue(shopping.trashed) + assertEquals(1_704_326_400_000L, shopping.edited) + assertEquals(1_704_164_645_678L, shopping.created) + assertEquals(listOf("check:[x] milk"), describe(shopping.blocks)) + val plain = parsed.notes.first { it.title == "Plain" } + assertEquals(listOf("Personal"), plain.tags) + assertFalse(plain.trashed) + assertEquals(1, parsed.extraLosses.attachments) + assertEquals(1, parsed.skips.unsupported) + } + + private fun describe(blocks: List) = ImportFixtures.describe(blocks) +} + +class MarkdownNotesParserTest { + private fun localMillis(year: Int, month: Int, day: Int, hour: Int, minute: Int) = + java.time.LocalDateTime.of(year, month, day, hour, minute).atZone(java.time.ZoneId.systemDefault()) + .toInstant().toEpochMilli() + + private fun generic(vararg e: ImportEntry) = parse(ImportSource.MARKDOWN, *e) + + private fun notesnook(vararg e: ImportEntry) = parse(ImportSource.NOTESNOOK, *e) + + @Test + fun theFileNameIsTheTitleAndOnlyARepeatedHeadingIsDropped() { + val kept = generic(entry("Plan.md", "# Weekly sync\n\ntext")).notes.single() + assertEquals("Plan", kept.title) + assertEquals(listOf("h1:Weekly sync", "text:text"), describe(kept.blocks)) + val dropped = generic(entry("Plan.md", "# Plan\n\ntext")).notes.single() + assertEquals(listOf("text:text"), describe(dropped.blocks)) + } + + @Test + fun frontMatterGivesTitleTagsAndDates() { + val note = generic( + entry( + "x.md", + "---\ntitle: Real title\ntags:\n - b\n - '#a'\ncreated: 2024-03-01 10:00:00Z\n" + + "updated: 2024-03-02\n---\nbody", + ), + ).notes.single() + assertEquals("Real title", note.title) + assertEquals(listOf("b", "a"), note.tags) + assertEquals(1_709_287_200_000L, note.created) + assertEquals(localMillis(2024, 3, 2, 0, 0), note.edited) + assertEquals(listOf("text:body"), describe(note.blocks)) + } + + @Test + fun theFirstFolderIsTheTagWhenThereAreNoTags() { + val notes = generic(entry("Vault/Ideas/x.md", "a"), entry("y.txt", "b")).notes + assertEquals(listOf("Vault"), notes[0].tags) + assertEquals(emptyList(), notes[1].tags) + } + + @Test + fun anEmptyFileKeepsItsTitle() { + val note = generic(entry("Empty.md", "")).notes.single() + assertEquals("Empty", note.title) + assertTrue(note.blocks.isEmpty()) + } + + @Test + fun invalidUtf8IsSkippedAndReported() { + val parsed = generic(ImportEntry("bad.md", byteArrayOf(0xc3.toByte(), 0x28)), entry("ok.md", "fine")) + assertEquals(1, parsed.notes.size) + assertEquals(1, parsed.skips.unreadable) + } + + @Test + fun notesnookFrontMatterFlagsFoldersAndAttachments() { + val parsed = notesnook( + entry( + "Work/Plan.md", + "---\ntitle: \"Plan \\\"A\\\"\"\ncreated_at: 2024-01-01T00:00:00Z\n" + + "updated_at: 2024-01-02T00:00:00Z\npinned: true\ncolor: red\ntags: x, y\n---\n\n" + + "# Plan \"A\"\n\n- [x] done\n", + ), + entry("_archive/Work/Old.md", "old text"), + entry("Work/attachments/pic.png", "x"), + entry("Trip.txt", "Trip\n\nintro\n\n✅ Passport\n☐ Tickets\n☐ Charger\nbye"), + ) + assertEquals(3, parsed.notes.size) + val plan = parsed.notes.first { it.title == "Plan \"A\"" } + assertEquals(listOf("x", "y"), plan.tags) + assertEquals(listOf("check:[x] done"), describe(plan.blocks)) + assertEquals(ImportLosses(pinned = 1, colors = 1, extraTags = 1), plan.losses) + assertEquals(1_704_067_200_000L, plan.created) + val old = parsed.notes.first { it.title == "Old" } + assertEquals(listOf("Work"), old.tags) + assertEquals(1, old.losses.archived) + val trip = parsed.notes.first { it.title == "Trip" } + assertEquals( + listOf("text:intro", "check:[x] Passport|[ ] Tickets|[ ] Charger", "text:bye"), + describe(trip.blocks), + ) + assertEquals(1, parsed.extraLosses.attachments) + } +} diff --git a/app/src/test/java/com/pasich/encly/data/importer/ZipStreamReaderTest.kt b/app/src/test/java/com/pasich/encly/data/importer/ZipStreamReaderTest.kt new file mode 100644 index 00000000..bf688772 --- /dev/null +++ b/app/src/test/java/com/pasich/encly/data/importer/ZipStreamReaderTest.kt @@ -0,0 +1,114 @@ +package com.pasich.encly.data.importer + +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertThrows +import org.junit.Test +import java.io.ByteArrayOutputStream +import java.io.IOException +import java.util.zip.CRC32 +import java.util.zip.ZipEntry +import java.util.zip.ZipOutputStream + +class ZipStreamReaderTest { + private fun readAll(zip: ByteArray): List> { + val reader = ZipStreamReader(zip.inputStream()) + val out = ArrayList>() + while (true) { + val entry = reader.next() ?: return out + out += entry.name to reader.data().readBytes() + } + } + + @Test + fun readsDeflatedEntriesWithTheJdksDescriptorAndSkipsDirectories() { + val zip = ImportFixtures.zip("a.txt" to "hello".toByteArray(), "dir/b.txt" to ByteArray(100_000) { 7 }) + val entries = readAll(zip) + assertEquals(listOf("a.txt", "dir/b.txt"), entries.map { it.first }) + assertEquals("hello", String(entries[0].second)) + assertEquals(100_000, entries[1].second.size) + } + + @Test + fun readsStoredEntriesWithKnownSizes() { + val data = "stored text".toByteArray() + val out = ByteArrayOutputStream() + ZipOutputStream(out).use { zip -> + val entry = ZipEntry("s.txt").apply { + method = ZipEntry.STORED + size = data.size.toLong() + compressedSize = data.size.toLong() + crc = CRC32().apply { update(data) }.value + } + zip.putNextEntry(entry) + zip.write(data) + zip.closeEntry() + } + assertArrayEquals(data, readAll(out.toByteArray()).single().second) + } + + @Test + fun anEntryLeftUnreadIsSkippedToTheNext() { + val zip = ImportFixtures.zip("a.txt" to ByteArray(50_000) { 1 }, "b.txt" to "b".toByteArray()) + val reader = ZipStreamReader(zip.inputStream()) + assertEquals("a.txt", reader.next()?.name) + assertEquals("b.txt", reader.next()?.name) + assertEquals("b", String(reader.data().readBytes())) + assertNull(reader.next()) + } + + @Test + fun aDamagedStoredEntryFailsItsChecksum() { + val data = "stored text".toByteArray() + val out = ByteArrayOutputStream() + ZipOutputStream(out).use { zip -> + val entry = ZipEntry("s.txt").apply { + method = ZipEntry.STORED + size = data.size.toLong() + compressedSize = data.size.toLong() + crc = CRC32().apply { update(data) }.value + } + zip.putNextEntry(entry) + zip.write(data) + zip.closeEntry() + } + val bytes = out.toByteArray() + // The first copy of the text is the entry's data (the name is shorter). + bytes[String(bytes, Charsets.ISO_8859_1).indexOf("stored text")] = 'X'.code.toByte() + assertThrows(IOException::class.java) { readAll(bytes) } + } + + @Test + fun aTruncatedZipIsAnIoError() { + val zip = ImportFixtures.zip("a.txt" to ByteArray(10_000) { (it % 251).toByte() }) + assertThrows(IOException::class.java) { readAll(zip.copyOf(zip.size / 2)) } + } + + @Test + fun readsZip64StreamsWithA64BitDescriptorAsStreamingWritersProduceThem() { + // Notesnook's export: ZIP64 local headers, sizes in a data descriptor without a signature. + val zip = ImportFixtures.resource("notesnook/notes-md.zip") + assertNotNull("real export fixture missing", zip) + val entries = readAll(zip!!) + assertEquals(5, entries.size) + assertEquals("# Groceries", String(entries.first { it.first == "Groceries.md" }.second).lines().first()) + } + + @Test + fun aDirectoryEntryWithDataIsRefusedInsteadOfInflatedForever() { + val out = ByteArrayOutputStream() + ZipOutputStream(out).use { zip -> + zip.putNextEntry(ZipEntry("d/")) + zip.write(ByteArray(8 * 1024 * 1024)) + zip.closeEntry() + zip.putNextEntry(ZipEntry("after.txt")) + zip.write(1) + zip.closeEntry() + } + val reader = ZipStreamReader(out.toByteArray().inputStream()) + assertEquals("d/", reader.next()?.name) + assertThrows(IOException::class.java) { reader.next() } + } +} diff --git a/app/src/test/java/com/pasich/encly/i18n/TranslationCompletenessTest.kt b/app/src/test/java/com/pasich/encly/i18n/TranslationCompletenessTest.kt index d756ad2e..4187fff9 100644 --- a/app/src/test/java/com/pasich/encly/i18n/TranslationCompletenessTest.kt +++ b/app/src/test/java/com/pasich/encly/i18n/TranslationCompletenessTest.kt @@ -198,7 +198,8 @@ class TranslationCompletenessTest { private companion object { const val DEFAULT_LOCALE = "en" const val MIN_EXPECTED_KEYS = 100 - val TRANSLATION_COMPONENTS = listOf("strings.xml", "strings_security.xml") + val TRANSLATION_COMPONENTS = + listOf("strings.xml", "strings_security.xml", "strings_handoff.xml", "strings_import.xml") val LOCALE_DIR = Regex("""values-[a-z]{2,3}(-r[A-Z]{2})?""") val LOCALE_FILTERS = Regex("""localeFilters\s*\+=\s*listOf\(([^)]*)\)""") val FORMAT_ARG = Regex("""%(\d+\$)?[-#+ 0,(]*\d*(\.\d+)?[a-zA-Z%]""") diff --git a/app/src/test/java/com/pasich/encly/presentation/viewmodel/ImportNotesViewModelTest.kt b/app/src/test/java/com/pasich/encly/presentation/viewmodel/ImportNotesViewModelTest.kt new file mode 100644 index 00000000..0a39a693 --- /dev/null +++ b/app/src/test/java/com/pasich/encly/presentation/viewmodel/ImportNotesViewModelTest.kt @@ -0,0 +1,334 @@ +package com.pasich.encly.presentation.viewmodel + +import android.app.Application +import android.content.Context +import androidx.lifecycle.LifecycleOwner +import androidx.test.core.app.ApplicationProvider +import com.pasich.encly.core.security.SecurityManager +import com.pasich.encly.core.security.SessionLockManager +import com.pasich.encly.data.backup.VaultDataStore +import com.pasich.encly.data.importer.ImportFixtures +import com.pasich.encly.data.importer.ImportSource +import com.pasich.encly.data.importer.SafImportFiles +import com.pasich.encly.data.model.Tag +import com.pasich.encly.testutil.InMemoryVaultDataStore +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mockito.mock +import org.mockito.Mockito.`when` +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** Detect -> review -> import -> summary, with an in-memory vault. */ +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35], application = Application::class) +class ImportNotesViewModelTest { + private val context: Context get() = ApplicationProvider.getApplicationContext() + private val dispatcher = UnconfinedTestDispatcher() + private lateinit var security: SecurityManager + private lateinit var sessionLock: SessionLockManager + private lateinit var store: InMemoryVaultDataStore + + @Before + fun setUp() { + Dispatchers.setMain(dispatcher) + security = mock(SecurityManager::class.java) + `when`(security.isDatabaseUnlocked()).thenReturn(true) + sessionLock = SessionLockManager(security) + sessionLock.onStart(mock(LifecycleOwner::class.java)) + store = InMemoryVaultDataStore() + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + private fun viewModel(vault: VaultDataStore = store) = + ImportNotesViewModel(SafImportFiles(context), vault, sessionLock, dispatcher) + + private fun keep(title: String, text: String, created: Long, label: String? = null, trashed: Boolean = false) = + "$title.json" to ( + """{"createdTimestampUsec":$created,"userEditedTimestampUsec":$created,"title":"$title",""" + + """"textContent":"$text","isTrashed":$trashed""" + + (label?.let { ""","labels":[{"name":"$it"}]""" } ?: "") + "}" + ) + + private val files = arrayOf( + keep("One", "first", 1_000_000_000_000_000, label = "Work"), + keep("Two", "second", 1_000_000_001_000_000, label = "Home"), + keep("Three", "third", 1_000_000_002_000_000, trashed = true), + ) + + private fun ImportNotesViewModel.preview() = (step.value as ImportStep.Preview) + + @Test + fun detectsPreviewsAndImportsEverythingNewByDefault() { + val vm = viewModel() + vm.begin(ImportFixtures.textSource(*files)) + + val preview = vm.preview() + assertEquals(ImportSource.GOOGLE_KEEP, preview.preview.source) + assertEquals(3, preview.preview.notes) + assertEquals(3, preview.review.selected.size) + assertEquals(1, preview.preview.trashed) + // Nothing is written before the user confirms. + assertTrue(store.notes.isEmpty()) + + vm.confirm() + + val done = (vm.step.value as ImportStep.Done).result + assertEquals(3, done.imported) + assertEquals(0, done.duplicates) + assertEquals(2, done.tagsAdded) + assertEquals(listOf("One", "Two", "Three"), store.notes.map { it.title }) + assertEquals(listOf(false, false, true), store.notes.map { it.isTrash }) + } + + @Test + fun theReviewListCarriesRowsWithoutTheirFullText() { + val vm = viewModel() + vm.begin(ImportFixtures.textSource(*files)) + + val rows = vm.preview().review.rows + assertEquals(listOf("One", "Two", "Three"), rows.map { it.title }) + assertEquals("first", rows[0].snippet) + assertEquals("Work", rows[0].tag) + assertEquals(listOf(false, false, true), rows.map { it.trashed }) + } + + @Test + fun anImportedFileImportedAgainIsAllDuplicatesAndNothingIsSelected() { + viewModel().apply { + begin(ImportFixtures.textSource(*files)) + confirm() + } + + val again = viewModel() + again.begin(ImportFixtures.textSource(*files)) + + val review = again.preview().review + assertEquals(3, again.preview().preview.duplicates) + assertTrue(review.rows.all { it.existing }) + assertTrue(review.selected.isEmpty()) + // Duplicates cannot be ticked, and an empty selection imports nothing. + again.toggle(review.rows.first().uid) + assertTrue(again.preview().review.selected.isEmpty()) + again.confirm() + assertTrue(again.step.value is ImportStep.Preview) + assertEquals(3, store.notes.size) + } + + @Test + fun onlyTheSelectedNotesAndTheirTagsAreWritten() { + val vm = viewModel() + vm.begin(ImportFixtures.textSource(*files)) + val uids = vm.preview().review.rows.associate { it.title to it.uid } + + vm.toggleAll() + assertTrue(vm.preview().review.selected.isEmpty()) + vm.toggle(uids.getValue("Two")) + vm.toggle(uids.getValue("Three")) + assertEquals(setOf(uids.getValue("Two"), uids.getValue("Three")), vm.preview().review.selected) + vm.toggle(uids.getValue("Three")) + vm.confirm() + + val done = (vm.step.value as ImportStep.Done).result + assertEquals(1, done.imported) + assertEquals(2, done.notImported) + assertEquals(listOf("Two"), store.notes.map { it.title }) + assertEquals(listOf("Home"), store.tags.map { it.nameTag }) + } + + @Test + fun selectAllAndNoneToggleAndTheFilterOnlyChangesTheView() { + val vm = viewModel() + vm.begin(ImportFixtures.textSource(*files)) + + vm.toggleAll() + assertTrue(vm.preview().review.selected.isEmpty()) + vm.toggleAll() + assertEquals(3, vm.preview().review.selected.size) + + vm.setFilter(ReviewFilter.TRASH) + assertEquals(listOf("Three"), vm.preview().review.visible.map { it.title }) + assertEquals(3, vm.preview().review.selected.size) + vm.setFilter(ReviewFilter.NEW) + assertEquals(3, vm.preview().review.visible.size) + } + + @Test + fun expandingARowShowsItsTextAndOnlyOneAtATime() { + val vm = viewModel() + vm.begin(ImportFixtures.textSource(*files)) + val rows = vm.preview().review.rows + + vm.expand(rows[0].uid) + assertEquals("first", vm.preview().review.expandedText) + vm.expand(rows[1].uid) + assertEquals("second", vm.preview().review.expandedText) + vm.expand(rows[1].uid) + assertEquals(null, vm.preview().review.expanded) + } + + @Test + fun aTagTheVaultHasIsReusedByName() { + runBlocking { store.insertTag(Tag(nameTag = "work", position = 0, uid = "mine")) } + val vm = viewModel() + vm.begin(ImportFixtures.textSource(*files)) + vm.confirm() + + assertEquals(listOf("work", "Home"), store.tags.map { it.nameTag }) + assertEquals(store.tags.first().id, store.notes.first { it.title == "One" }.tagId) + } + + @Test + fun cancelWritesNothingAndForgetsEverything() { + val vm = viewModel() + vm.begin(ImportFixtures.textSource(*files)) + + vm.cancel() + + assertEquals(ImportStep.Start, vm.step.value) + vm.confirm() + assertEquals(ImportStep.Start, vm.step.value) + assertTrue(store.notes.isEmpty() && store.tags.isEmpty()) + } + + @Test + fun aRelockClearsTheParsedNotesAndTheSelection() { + val vm = viewModel() + vm.begin(ImportFixtures.textSource(*files)) + assertTrue(vm.step.value is ImportStep.Preview) + + sessionLock.requireUnlock() + + assertEquals(ImportStep.Start, vm.step.value) + // Nothing is left to import, toggle or expand. + vm.confirm() + vm.toggleAll() + vm.expand("anything") + assertEquals(ImportStep.Start, vm.step.value) + assertTrue(store.notes.isEmpty()) + } + + @Test + fun aRelockWhileChoosingTheSourceClearsTheReadFiles() { + val vm = viewModel() + vm.begin( + ImportFixtures.textSource( + files[0], + "n.md" to "---\ntitle: T\ncreated_at: 2024-01-01\nupdated_at: 2024-01-01\n---\nbody", + ), + ) + assertEquals(ImportStep.ChooseSource, vm.step.value) + + sessionLock.requireUnlock() + vm.chooseSource(ImportSource.GOOGLE_KEEP) + + assertEquals(ImportStep.Start, vm.step.value) + } + + @Test + fun anAmbiguousSelectionAsksAndTheChoiceStillValidates() { + val vm = viewModel() + val md = "n.md" to "---\ntitle: T\ncreated_at: 2024-01-01\nupdated_at: 2024-01-01\n---\nbody" + vm.begin(ImportFixtures.textSource(files[0], md)) + assertEquals(ImportStep.ChooseSource, vm.step.value) + + vm.chooseSource(ImportSource.JOPLIN) + assertEquals(ImportStep.Failed(ImportFailure.UNSUPPORTED), vm.step.value) + + vm.cancel() + vm.begin(ImportFixtures.textSource(files[0], md)) + vm.chooseSource(ImportSource.GOOGLE_KEEP) + assertEquals(1, vm.preview().preview.notes) + } + + @Test + fun encryptedAndUnsupportedSelectionsFailWithGuidance() { + val sn = """{"version":"004","items":[],"keyParams":{}}""" + viewModel().apply { + begin(ImportFixtures.textSource("backup.json" to sn)) + assertEquals(ImportStep.Failed(ImportFailure.ENCRYPTED_STANDARD_NOTES), step.value) + } + viewModel().apply { + begin(ImportFixtures.textSource("my.nnbackup" to "x")) + assertEquals(ImportStep.Failed(ImportFailure.ENCRYPTED_NOTESNOOK), step.value) + } + viewModel().apply { + begin(ImportFixtures.textSource("data.json" to """{"a":1}""")) + assertEquals(ImportStep.Failed(ImportFailure.UNSUPPORTED), step.value) + } + viewModel().apply { + begin(ImportFixtures.textSource()) + assertEquals(ImportStep.Failed(ImportFailure.NOTHING_FOUND), step.value) + } + assertTrue(store.notes.isEmpty()) + } + + @Test + fun aSelectionOverTheLimitsIsTooLarge() { + val big = ByteArray(5 * 1024 * 1024) + val zip = ImportFixtures.zip(*(1..16).map { "f$it.md" to big }.toTypedArray()) + val vm = viewModel() + vm.begin(ImportFixtures.source("bomb.zip" to zip)) + assertEquals(ImportStep.Failed(ImportFailure.TOO_LARGE), vm.step.value) + } + + @Test + fun aFailedTransactionLeavesTheVaultUntouched() { + store.failOnInsert = 3 + val vm = viewModel() + vm.begin(ImportFixtures.textSource(*files)) + + vm.confirm() + + assertEquals(ImportStep.Failed(ImportFailure.IMPORT), vm.step.value) + assertTrue(store.notes.isEmpty() && store.tags.isEmpty()) + } + + @Test + fun aVaultThatCannotBeBoundFailsBeforeReading() { + val locked = object : VaultDataStore by store { + override fun bindToCurrentSession(): VaultDataStore = error("Vault locked") + } + val vm = viewModel(locked) + vm.begin(ImportFixtures.textSource(*files)) + assertEquals(ImportStep.Failed(ImportFailure.VAULT), vm.step.value) + } + + @Test + fun aUnreadableFileIsReportedNotFatal() { + val vm = viewModel() + vm.begin( + ImportFixtures.source( + files[0].first to files[0].second.toByteArray(), + "bad.json" to byteArrayOf(0x7b, 0xff.toByte()), + ), + ) + assertEquals(1, vm.preview().preview.filesSkipped) + vm.confirm() + assertEquals(1, (vm.step.value as ImportStep.Done).result.notImported) + } + + @Test + fun theStartPageIsTheInitialStepAndThePickerGraceIsAskedFor() { + val vm = viewModel() + assertEquals(ImportStep.Start, vm.step.value) + vm.onPickerLaunched() + vm.onPickerUnavailable() + assertEquals(ImportStep.Failed(ImportFailure.NO_PICKER), vm.step.value) + } +} diff --git a/app/src/test/java/com/pasich/encly/ui/screens/ImportNotesScreenTest.kt b/app/src/test/java/com/pasich/encly/ui/screens/ImportNotesScreenTest.kt new file mode 100644 index 00000000..2d17ee71 --- /dev/null +++ b/app/src/test/java/com/pasich/encly/ui/screens/ImportNotesScreenTest.kt @@ -0,0 +1,242 @@ +package com.pasich.encly.ui.screens + +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsEnabled +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.assertIsOff +import androidx.compose.ui.test.assertIsOn +import androidx.compose.ui.test.hasClickAction +import androidx.compose.ui.test.hasScrollToNodeAction +import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.isToggleable +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollTo +import androidx.compose.ui.test.performScrollToNode +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.data.importer.ReviewNote +import com.pasich.encly.presentation.screen.importnotes.ImportNotesActions +import com.pasich.encly.presentation.screen.importnotes.ImportNotesContent +import com.pasich.encly.presentation.viewmodel.ImportFailure +import com.pasich.encly.presentation.viewmodel.ImportResult +import com.pasich.encly.presentation.viewmodel.ImportStep +import com.pasich.encly.presentation.viewmodel.ReviewFilter +import com.pasich.encly.presentation.viewmodel.ReviewState +import org.junit.Assert.assertEquals +import org.junit.Test + +/** Every state of the Import notes page, stateless, with recorded callbacks. */ +class ImportNotesScreenTest : ComposeScreenTest() { + private val calls = mutableListOf() + + private val actions = ImportNotesActions( + onChooseFiles = { calls += "files" }, + onChooseFolder = { calls += "folder" }, + onImport = { calls += "import" }, + onCancel = { calls += "cancel" }, + onChooseSource = { calls += "source:${it.name}" }, + onToggle = { calls += "toggle:$it" }, + onToggleAll = { calls += "all" }, + onFilter = { calls += "filter:${it.name}" }, + onExpand = { calls += "expand:$it" }, + ) + + private val current = mutableStateOf(ImportStep.Start) + private var shown = false + + /** Shows [step]; a second call swaps the step in the same composition. */ + private fun show(step: ImportStep) { + current.value = step + if (shown) { + rule.waitForIdle() + } else { + shown = true + setScreen { ImportNotesContent(current.value, actions, onBack = { calls += "back" }) } + } + } + + private fun scrollTo(text: String) { + rule.onNode(hasScrollToNodeAction()).performScrollToNode(hasText(text, substring = true)) + } + + private fun row(uid: String, title: String, existing: Boolean = false, trashed: Boolean = false) = ReviewNote( + uid = uid, + title = title, + snippet = "snippet of $title", + hasChecklist = uid == "a", + tag = if (uid == "a") "home" else null, + trashed = trashed, + date = 1_700_000_000_000, + existing = existing, + ) + + private val rows = listOf( + row("a", "Groceries"), + row("b", "Old draft", trashed = true), + row("c", "Seen before", existing = true), + row("d", ""), + ) + + private val plain = + ImportPreview(source = ImportSource.GOOGLE_KEEP, notes = 4, checklists = 1, tags = 1, trashed = 1) + + private val preview = plain.copy( + duplicates = 1, + losses = ImportLosses(pinned = 2, attachments = 3), + unsupported = 1, + filesSkipped = 2, + ) + + private fun review( + selected: Set = setOf("a", "b", "d"), + filter: ReviewFilter = ReviewFilter.ALL, + header: ImportPreview = plain, + ) = ImportStep.Preview(header, ReviewState(rows, selected, filter)) + + @Test + fun startListsTheSupportedAppsAndTheTwoPickers() { + show(ImportStep.Start) + + rule.onNodeWithText(str(R.string.import_intro)).assertIsDisplayed() + rule.onNodeWithText(str(R.string.import_choose_files)).assertIsEnabled().performClick() + rule.onNodeWithText(str(R.string.import_choose_folder)).performClick() + assertEquals(listOf("files", "folder"), calls) + ImportSource.entries.forEach { source -> + rule.onNodeWithText(str(source.title)).performScrollTo().assertIsDisplayed() + rule.onNodeWithText(str(source.formats)).assertExists() + rule.onNodeWithText(str(source.howTo)).assertExists() + rule.onNodeWithText(str(source.losses)).assertExists() + } + assertBodyNotEmpty(minTexts = 4) + } + + @Test + fun reviewShowsTheDetectedSourceCountsLossesAndEveryNote() { + show(review(header = preview)) + + rule.onNodeWithText(str(R.string.import_preview_title)).assertIsDisplayed() + rule.onNodeWithText( + str(R.string.import_detected, str(R.string.import_keep_name), plural(R.plurals.backup_count_notes, 4, 4)), + ).assertIsDisplayed() + rule.onNodeWithText(str(R.string.import_stat_checklists, 1)).assertIsDisplayed() + rule.onNodeWithText(str(R.string.import_stat_destination)).assertExists() + rule.onNode(hasText(str(R.string.import_loss_pinned, 2), substring = true)).assertExists() + rule.onNode(hasText(str(R.string.import_loss_unsupported, 1), substring = true)).assertExists() + rule.onNodeWithText(str(R.string.import_review_selected, 3, 3)).assertIsDisplayed() + scrollTo("Groceries") + rule.onNodeWithText("Groceries").assertIsDisplayed() + } + + @Test + fun newNotesAreTickedAndAnExistingOneIsNotAndCannotBe() { + show(review()) + + scrollTo(str(R.string.import_untitled)) + val boxes = rule.onAllNodes(isToggleable(), useUnmergedTree = true) + val shown = boxes.fetchSemanticsNodes().size + // The last rows are on screen: Old draft, Seen before, Untitled, in that order. + boxes[shown - 3].assertIsOn() + boxes[shown - 2].assertIsOff().assertIsNotEnabled() + boxes[shown - 1].assertIsOn() + rule.onNode(hasText(str(R.string.import_badge_in_vault), substring = true)).assertExists() + rule.onNodeWithText(str(R.string.import_untitled)).assertExists() + } + + @Test + fun tappingACheckboxTogglesAndTappingARowExpandsIt() { + show(review()) + + scrollTo("Old draft") + val boxes = rule.onAllNodes(isToggleable(), useUnmergedTree = true) + boxes[boxes.fetchSemanticsNodes().size - 3].performClick() + rule.onNodeWithText("Old draft").performClick() + assertEquals(listOf("toggle:b", "expand:b"), calls) + } + + @Test + fun selectNoneDisablesImportAndSelectAllEnablesIt() { + show(review(selected = emptySet())) + + rule.onNodeWithText(str(R.string.import_action_import_count, 0)).assertIsNotEnabled() + rule.onNodeWithText(str(R.string.import_select_all)).performClick() + assertEquals(listOf("all"), calls) + } + + @Test + fun withASelectionImportIsEnabledAndCountsIt() { + show(review()) + + rule.onNodeWithText(str(R.string.import_select_none)).assertIsDisplayed() + rule.onNodeWithText(str(R.string.import_action_import_count, 3)).assertIsEnabled().performClick() + rule.onNodeWithText(str(R.string.cancel)).performClick() + assertEquals(listOf("import", "cancel"), calls) + } + + @Test + fun filtersListOnlyWhatTheyShowAndReportTheTap() { + show(review(filter = ReviewFilter.TRASH).let { it.copy(review = it.review.copy(rows = it.review.rows)) }) + + rule.onNodeWithText("Old draft").assertIsDisplayed() + rule.onNodeWithText("Groceries").assertDoesNotExist() + rule.onNodeWithText(str(R.string.import_filter_new)).performClick() + assertEquals(listOf("filter:NEW"), calls) + } + + @Test + fun anExpandedRowShowsItsReadOnlyText() { + val open = review() + val step = open.copy(review = open.review.copy(expanded = "a", expandedText = "☑ Milk\n☐ Eggs")) + show(step) + + rule.onNodeWithText("☑ Milk\n☐ Eggs").assertIsDisplayed() + } + + @Test + fun ambiguousSelectionsOfferEverySourceAndReportTheChoice() { + show(ImportStep.ChooseSource) + + rule.onNodeWithText(str(R.string.import_choose_source_title)).assertIsDisplayed() + ImportSource.entries.forEach { rule.onNodeWithText(str(it.title)).assertExists() } + rule.onNodeWithText(str(R.string.import_joplin_name)).performClick() + assertEquals(listOf("source:JOPLIN"), calls) + } + + @Test + fun theSummaryShowsTheCountsAndTheOriginalsStayUntouched() { + show(ImportStep.Done(ImportResult(imported = 3, duplicates = 1, notImported = 2, tagsAdded = 1))) + + rule.onNodeWithText(str(R.string.import_done_title)).assertIsDisplayed() + rule.onNodeWithText(str(R.string.import_done_body, 3, 1, 2)).assertIsDisplayed() + rule.onNodeWithText(str(R.string.import_done_hint)).assertIsDisplayed() + rule.onNode(hasText(str(R.string.done)) and hasClickAction()).performClick() + assertEquals(listOf("cancel"), calls) + } + + @Test + fun everyErrorHasItsOwnMessageAndWayBack() { + val messages = mapOf( + ImportFailure.ENCRYPTED_STANDARD_NOTES to R.string.import_error_encrypted_sn, + ImportFailure.ENCRYPTED_NOTESNOOK to R.string.import_error_encrypted_nn, + ImportFailure.UNSUPPORTED to R.string.import_error_unsupported, + ImportFailure.TOO_LARGE to R.string.import_error_too_large, + ImportFailure.NOTHING_FOUND to R.string.import_error_nothing, + ImportFailure.UNREADABLE to R.string.import_error_unreadable, + ImportFailure.NO_PICKER to R.string.import_error_no_picker, + ImportFailure.VAULT to R.string.import_error_vault, + ImportFailure.IMPORT to R.string.import_error_failed, + ) + assertEquals(ImportFailure.entries.toSet(), messages.keys) + messages.forEach { (failure, message) -> + calls.clear() + show(ImportStep.Failed(failure)) + rule.onNodeWithText(str(message)).assertIsDisplayed() + rule.onNodeWithText(str(R.string.import_error_title)).assertIsDisplayed() + rule.onNodeWithText(str(R.string.import_choose_again)).performClick() + assertEquals(listOf("cancel"), calls) + } + } +} diff --git a/app/src/test/resources/import-fixtures/joplin/README.md b/app/src/test/resources/import-fixtures/joplin/README.md new file mode 100644 index 00000000..a1b507ff --- /dev/null +++ b/app/src/test/resources/import-fixtures/joplin/README.md @@ -0,0 +1,7 @@ +Real exports made with the Joplin CLI 3.7.1 (`npm install joplin@3.7.1`) from a fresh profile with +synthetic notes only: `export --format jex|raw|md|md_frontmatter`. + +- Personal: "Groceries" (checklist: Milk done, Eggs, "Хліб 🍞"; tag home), "Ідеї 💡" (two paragraphs). +- Work: "Meeting notes" (H1, H2, bullets, numbered, quote, rule; tags work, important), + "Call the bank" (to-do, done), "Renew passport" (to-do, open). +- "Old draft" was moved to the trash; Joplin does not export trashed notes. diff --git a/app/src/test/resources/import-fixtures/joplin/md/Personal/Groceries.md b/app/src/test/resources/import-fixtures/joplin/md/Personal/Groceries.md new file mode 100644 index 00000000..1b01dc37 --- /dev/null +++ b/app/src/test/resources/import-fixtures/joplin/md/Personal/Groceries.md @@ -0,0 +1,3 @@ +- [x] Milk +- [ ] Eggs +- [ ] Хліб 🍞 \ No newline at end of file diff --git "a/app/src/test/resources/import-fixtures/joplin/md/Personal/\320\206\320\264\320\265\321\227 \360\237\222\241.md" "b/app/src/test/resources/import-fixtures/joplin/md/Personal/\320\206\320\264\320\265\321\227 \360\237\222\241.md" new file mode 100644 index 00000000..349de4b4 --- /dev/null +++ "b/app/src/test/resources/import-fixtures/joplin/md/Personal/\320\206\320\264\320\265\321\227 \360\237\222\241.md" @@ -0,0 +1,3 @@ +Перша думка. + +Друга думка з **жирним** текстом. \ No newline at end of file diff --git a/app/src/test/resources/import-fixtures/joplin/md/Work/Call the bank.md b/app/src/test/resources/import-fixtures/joplin/md/Work/Call the bank.md new file mode 100644 index 00000000..e69de29b diff --git a/app/src/test/resources/import-fixtures/joplin/md/Work/Meeting notes.md b/app/src/test/resources/import-fixtures/joplin/md/Work/Meeting notes.md new file mode 100644 index 00000000..56850a8f --- /dev/null +++ b/app/src/test/resources/import-fixtures/joplin/md/Work/Meeting notes.md @@ -0,0 +1,15 @@ +# Weekly sync + +## Agenda + +- Release 2.2 +- Translations + +1. Review PRs +2. Ship + +> Decisions are final. + +--- + +Plain closing paragraph. \ No newline at end of file diff --git a/app/src/test/resources/import-fixtures/joplin/md/Work/Renew passport.md b/app/src/test/resources/import-fixtures/joplin/md/Work/Renew passport.md new file mode 100644 index 00000000..e69de29b diff --git a/app/src/test/resources/import-fixtures/joplin/md_frontmatter/Personal/Groceries.md b/app/src/test/resources/import-fixtures/joplin/md_frontmatter/Personal/Groceries.md new file mode 100644 index 00000000..53cfc9ff --- /dev/null +++ b/app/src/test/resources/import-fixtures/joplin/md_frontmatter/Personal/Groceries.md @@ -0,0 +1,11 @@ +--- +title: Groceries +updated: 2026-10-14 19:32:48Z +created: 2026-10-14 19:32:48Z +tags: + - home +--- + +- [x] Milk +- [ ] Eggs +- [ ] Хліб 🍞 \ No newline at end of file diff --git "a/app/src/test/resources/import-fixtures/joplin/md_frontmatter/Personal/\320\206\320\264\320\265\321\227 \360\237\222\241.md" "b/app/src/test/resources/import-fixtures/joplin/md_frontmatter/Personal/\320\206\320\264\320\265\321\227 \360\237\222\241.md" new file mode 100644 index 00000000..210b6dbd --- /dev/null +++ "b/app/src/test/resources/import-fixtures/joplin/md_frontmatter/Personal/\320\206\320\264\320\265\321\227 \360\237\222\241.md" @@ -0,0 +1,9 @@ +--- +title: Ідеї 💡 +updated: 2026-10-14 19:32:50Z +created: 2026-10-14 19:32:50Z +--- + +Перша думка. + +Друга думка з **жирним** текстом. \ No newline at end of file diff --git a/app/src/test/resources/import-fixtures/joplin/md_frontmatter/Work/Call the bank.md b/app/src/test/resources/import-fixtures/joplin/md_frontmatter/Work/Call the bank.md new file mode 100644 index 00000000..03b9227a --- /dev/null +++ b/app/src/test/resources/import-fixtures/joplin/md_frontmatter/Work/Call the bank.md @@ -0,0 +1,7 @@ +--- +title: Call the bank +updated: 2026-10-14 19:32:56Z +created: 2026-10-14 19:32:55Z +completed?: yes +--- + diff --git a/app/src/test/resources/import-fixtures/joplin/md_frontmatter/Work/Meeting notes.md b/app/src/test/resources/import-fixtures/joplin/md_frontmatter/Work/Meeting notes.md new file mode 100644 index 00000000..fbbd5ef6 --- /dev/null +++ b/app/src/test/resources/import-fixtures/joplin/md_frontmatter/Work/Meeting notes.md @@ -0,0 +1,24 @@ +--- +title: Meeting notes +updated: 2026-10-14 19:32:53Z +created: 2026-10-14 19:32:53Z +tags: + - important + - work +--- + +# Weekly sync + +## Agenda + +- Release 2.2 +- Translations + +1. Review PRs +2. Ship + +> Decisions are final. + +--- + +Plain closing paragraph. \ No newline at end of file diff --git a/app/src/test/resources/import-fixtures/joplin/md_frontmatter/Work/Renew passport.md b/app/src/test/resources/import-fixtures/joplin/md_frontmatter/Work/Renew passport.md new file mode 100644 index 00000000..4ffd5a1a --- /dev/null +++ b/app/src/test/resources/import-fixtures/joplin/md_frontmatter/Work/Renew passport.md @@ -0,0 +1,7 @@ +--- +title: Renew passport +updated: 2026-10-14 19:32:57Z +created: 2026-10-14 19:32:57Z +completed?: no +--- + diff --git a/app/src/test/resources/import-fixtures/joplin/notes.jex b/app/src/test/resources/import-fixtures/joplin/notes.jex new file mode 100644 index 0000000000000000000000000000000000000000..1004aad7342c9251f9d3db1d2f76fa673ca502a4 GIT binary patch literal 16896 zcmeHOPjB2r6wle8!cuRMVvXmY$9q6RfH+mEN~KCg)oMH*Z%iC}WqWDEsZa@V;ers{ zs5o#hqJ^pyK7;d3_y|1jdQ&G`F%xa#mc)Bm+xzp_ety5-y!YlUVme4vh?t^+Mk0)2 z5pyZEPf$RVNW8;%XWI`#h*Ror3?Pd#MGU!L9BRi&I*6v!!n^l^T;QZ zqXGEODBj>d;h+~=bJE7v*PQ=L<{Ny#$%|SqSs{r`;sCsa6G|0MLIf%hV=NJqaTIW; zma`==ks(b4O>lrYB`Sdv0~+gy`6x*^#YQoqUg#sUn5m{=0bv zr?wG>$;+G`4HFGX1`bI;cnB3K9zk4@aH+!xE0`9zQHK!dP+-;}-y@vdYYPBC=OjxN z6bX=%$vUkUF%&o;1n53aR^+qIqcS@Ub>yPnFG^5`s#ugtPZwndJ8*%zh>Ldc_InyH z+QC=bsyLi4g`+*PI&0#7RzP(kUk|dZRVcWtR&#P)d#d;%Ro)7;r?S-PA=U+pqKq}% zh#!O)LU|G(u!wDtv@%|+&L+j}41G?;J(T6aVm{4eK3m8c*up!|K&T#O|9!RpSf*`ALf7??p>`9vLm-T$`$P1aNnQlJeKY3a2s-5S!ox0820Xz zGOyZbxya!>--Dk&PxS-$*6qr{p8LsuI(MA++z+)%s}|$J+M1iRK^)`+dwYA1b1Rc+ z?y9V)+8cljK+Z}z-|tcelVkR$m#s?|#1qIOWuP7ru)=vArk8+#MXaV5wlf| z)63;E+wKYx(J~Q{|^nw82=C9X>E1hs)!&_mINdv!@FiYN^U@dstVz=RKZz*yWgk9+- z;5PeXF^g#e9e@c}VH9GmR3ait8Ht8i|A&7XoX!6T@yGtZLHPfH%rdv$*AN8cT_cdm zB`!4mV_qmM`uw=Z(4dvK|4#o-7S=i3)@3f-Gq8?bD!4YJO)A(~^PDO$5oHCl3H{bX+K5_*J*TLhO4+Vg=(qGQ zQ@7m!{AUzuJ!k9xWB<<}^xwIq%c_7y*;VD= Decisions are final. + +--- + +Plain closing paragraph. + +id: a3afd784a6344c2f90026d22a05adb76 +parent_id: 6ca5bdfec28c489c9d18a75584c72238 +created_time: 2026-10-14T19:32:53.807Z +updated_time: 2026-10-14T19:32:54.386Z +is_conflict: 0 +latitude: 0.00000000 +longitude: 0.00000000 +altitude: 0.0000 +author: +source_url: +is_todo: 0 +todo_due: 0 +todo_completed: 0 +source: joplin +source_application: net.cozic.joplin-cli +application_data: +order: 1792006373807 +user_created_time: 2026-10-14T19:32:53.807Z +user_updated_time: 2026-10-14T19:32:53.807Z +encryption_cipher_text: +encryption_applied: 0 +markup_language: 1 +is_shared: 0 +share_id: +conflict_original_id: +master_key_id: +user_data: +deleted_time: 0 +is_locked: 0 +extracted_resource_ids: +type_: 1 \ No newline at end of file diff --git a/app/src/test/resources/import-fixtures/joplin/raw/b3eaf8f8050a42178bb80c2e53e8fa34.md b/app/src/test/resources/import-fixtures/joplin/raw/b3eaf8f8050a42178bb80c2e53e8fa34.md new file mode 100644 index 00000000..8e42f847 --- /dev/null +++ b/app/src/test/resources/import-fixtures/joplin/raw/b3eaf8f8050a42178bb80c2e53e8fa34.md @@ -0,0 +1,33 @@ +Renew passport + +id: b3eaf8f8050a42178bb80c2e53e8fa34 +parent_id: 6ca5bdfec28c489c9d18a75584c72238 +created_time: 2026-10-14T19:32:57.013Z +updated_time: 2026-10-14T19:32:57.013Z +is_conflict: 0 +latitude: 0.00000000 +longitude: 0.00000000 +altitude: 0.0000 +author: +source_url: +is_todo: 1 +todo_due: 0 +todo_completed: 0 +source: joplin +source_application: net.cozic.joplin-cli +application_data: +order: 1792006377012 +user_created_time: 2026-10-14T19:32:57.013Z +user_updated_time: 2026-10-14T19:32:57.013Z +encryption_cipher_text: +encryption_applied: 0 +markup_language: 1 +is_shared: 0 +share_id: +conflict_original_id: +master_key_id: +user_data: +deleted_time: 0 +is_locked: 0 +extracted_resource_ids: +type_: 1 \ No newline at end of file diff --git a/app/src/test/resources/import-fixtures/joplin/raw/ba98e539389140aa90f4cd70fb226964.md b/app/src/test/resources/import-fixtures/joplin/raw/ba98e539389140aa90f4cd70fb226964.md new file mode 100644 index 00000000..a1e25919 --- /dev/null +++ b/app/src/test/resources/import-fixtures/joplin/raw/ba98e539389140aa90f4cd70fb226964.md @@ -0,0 +1,11 @@ +id: ba98e539389140aa90f4cd70fb226964 +note_id: a3afd784a6344c2f90026d22a05adb76 +tag_id: c82ee80c86ff479c98f35a4e341e2164 +created_time: 2026-10-14T19:32:55.490Z +updated_time: 2026-10-14T19:32:55.490Z +user_created_time: 2026-10-14T19:32:55.490Z +user_updated_time: 2026-10-14T19:32:55.490Z +encryption_cipher_text: +encryption_applied: 0 +is_shared: 0 +type_: 6 \ No newline at end of file diff --git a/app/src/test/resources/import-fixtures/joplin/raw/c82ee80c86ff479c98f35a4e341e2164.md b/app/src/test/resources/import-fixtures/joplin/raw/c82ee80c86ff479c98f35a4e341e2164.md new file mode 100644 index 00000000..916183ce --- /dev/null +++ b/app/src/test/resources/import-fixtures/joplin/raw/c82ee80c86ff479c98f35a4e341e2164.md @@ -0,0 +1,13 @@ +important + +id: c82ee80c86ff479c98f35a4e341e2164 +created_time: 2026-10-14T19:32:55.487Z +updated_time: 2026-10-14T19:32:55.487Z +user_created_time: 2026-10-14T19:32:55.487Z +user_updated_time: 2026-10-14T19:32:55.487Z +encryption_cipher_text: +encryption_applied: 0 +is_shared: 0 +parent_id: +user_data: +type_: 5 \ No newline at end of file diff --git a/app/src/test/resources/import-fixtures/joplin/raw/eaf85d5d4ff94f6c9b92eccf8ba453bf.md b/app/src/test/resources/import-fixtures/joplin/raw/eaf85d5d4ff94f6c9b92eccf8ba453bf.md new file mode 100644 index 00000000..c906d2a9 --- /dev/null +++ b/app/src/test/resources/import-fixtures/joplin/raw/eaf85d5d4ff94f6c9b92eccf8ba453bf.md @@ -0,0 +1,33 @@ +Call the bank + +id: eaf85d5d4ff94f6c9b92eccf8ba453bf +parent_id: 6ca5bdfec28c489c9d18a75584c72238 +created_time: 2026-10-14T19:32:55.989Z +updated_time: 2026-10-14T19:32:56.506Z +is_conflict: 0 +latitude: 0.00000000 +longitude: 0.00000000 +altitude: 0.0000 +author: +source_url: +is_todo: 1 +todo_due: 0 +todo_completed: 1792006376506 +source: joplin +source_application: net.cozic.joplin-cli +application_data: +order: 1792006375989 +user_created_time: 2026-10-14T19:32:55.989Z +user_updated_time: 2026-10-14T19:32:56.506Z +encryption_cipher_text: +encryption_applied: 0 +markup_language: 1 +is_shared: 0 +share_id: +conflict_original_id: +master_key_id: +user_data: +deleted_time: 0 +is_locked: 0 +extracted_resource_ids: +type_: 1 \ No newline at end of file diff --git a/app/src/test/resources/import-fixtures/joplin/raw/fa94f84f272643cfa3a74deb510ff642.md b/app/src/test/resources/import-fixtures/joplin/raw/fa94f84f272643cfa3a74deb510ff642.md new file mode 100644 index 00000000..726ae352 --- /dev/null +++ b/app/src/test/resources/import-fixtures/joplin/raw/fa94f84f272643cfa3a74deb510ff642.md @@ -0,0 +1,13 @@ +work + +id: fa94f84f272643cfa3a74deb510ff642 +created_time: 2026-10-14T19:32:54.907Z +updated_time: 2026-10-14T19:32:54.907Z +user_created_time: 2026-10-14T19:32:54.907Z +user_updated_time: 2026-10-14T19:32:54.907Z +encryption_cipher_text: +encryption_applied: 0 +is_shared: 0 +parent_id: +user_data: +type_: 5 \ No newline at end of file diff --git a/app/src/test/resources/import-fixtures/joplin/raw/fe3af29373694b86b45af6ae9b2c6516.md b/app/src/test/resources/import-fixtures/joplin/raw/fe3af29373694b86b45af6ae9b2c6516.md new file mode 100644 index 00000000..9aa255e0 --- /dev/null +++ b/app/src/test/resources/import-fixtures/joplin/raw/fe3af29373694b86b45af6ae9b2c6516.md @@ -0,0 +1,17 @@ +Personal + +id: fe3af29373694b86b45af6ae9b2c6516 +created_time: 2026-10-14T19:32:47.818Z +updated_time: 2026-10-14T19:32:47.818Z +user_created_time: 2026-10-14T19:32:47.818Z +user_updated_time: 2026-10-14T19:32:47.818Z +encryption_cipher_text: +encryption_applied: 0 +parent_id: +is_shared: 0 +share_id: +master_key_id: +icon: +user_data: +deleted_time: 0 +type_: 2 \ No newline at end of file diff --git a/app/src/test/resources/import-fixtures/keep/README.md b/app/src/test/resources/import-fixtures/keep/README.md new file mode 100644 index 00000000..2759a004 --- /dev/null +++ b/app/src/test/resources/import-fixtures/keep/README.md @@ -0,0 +1,15 @@ +A real Google Takeout export of Google Keep (October 2026), reduced to the five synthetic notes +labelled `encly-test`. Their ZIP records (local header, deflated data, data descriptor, extra +fields; flags 0x808) are byte for byte as Takeout wrote them; only the central directory was +rebuilt for the smaller set, and `Labels.txt` (which lists every label of the account) was +replaced by one listing the two test labels. The reader was also run over the full original +export: all 540 entries read with matching CRCs. + +- "Encly test · Ідеї 💡": two paragraphs. +- "Encly test · Groceries": checklist; Takeout lists checked items last: Eggs, Хліб 🍞, Milk (checked); + labels encly-home, encly-test. +- "Encly test · Pinned reminder": isPinned. +- "Encly test · Archived recipe": isArchived. +- "Encly test · Old draft": isTrashed. + +Each note has a `.json` (the source of truth) and an `.html` copy; timestamps are microseconds. diff --git a/app/src/test/resources/import-fixtures/keep/takeout-keep.zip b/app/src/test/resources/import-fixtures/keep/takeout-keep.zip new file mode 100644 index 0000000000000000000000000000000000000000..b9b374dd0eb76e1e06e54ab45a7cba06b8ca64ed GIT binary patch literal 23922 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�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 literal 0 HcmV?d00001 diff --git a/app/src/test/resources/import-fixtures/markdown/README.md b/app/src/test/resources/import-fixtures/markdown/README.md new file mode 100644 index 00000000..43437e0c --- /dev/null +++ b/app/src/test/resources/import-fixtures/markdown/README.md @@ -0,0 +1,6 @@ +Generic Markdown/TXT input. `obsidian-vault/` is a folder as Obsidian keeps it on disk (Obsidian +has no export format; a vault is plain files): YAML frontmatter with tags as a list and as a flow +sequence, an `aliases` key, `- [ ]` tasks, a nested bullet, a callout (`> [!note]`), wiki links +`[[…]]` and `[[…|alias]]`, an inline `#tag`, a `.obsidian/` settings folder and an attachment that +must be skipped, plus a Markor-style `.txt` note. Joplin's Markdown exports in `../joplin/md` and +`../joplin/md_frontmatter` are real generic-Markdown inputs too. diff --git a/app/src/test/resources/import-fixtures/markdown/obsidian-vault/.obsidian/app.json b/app/src/test/resources/import-fixtures/markdown/obsidian-vault/.obsidian/app.json new file mode 100644 index 00000000..b098604d --- /dev/null +++ b/app/src/test/resources/import-fixtures/markdown/obsidian-vault/.obsidian/app.json @@ -0,0 +1,3 @@ +{ + "alwaysUpdateLinks": true +} diff --git a/app/src/test/resources/import-fixtures/markdown/obsidian-vault/.obsidian/workspace.json b/app/src/test/resources/import-fixtures/markdown/obsidian-vault/.obsidian/workspace.json new file mode 100644 index 00000000..9e26dfee --- /dev/null +++ b/app/src/test/resources/import-fixtures/markdown/obsidian-vault/.obsidian/workspace.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/app/src/test/resources/import-fixtures/markdown/obsidian-vault/Attachments/image.png b/app/src/test/resources/import-fixtures/markdown/obsidian-vault/Attachments/image.png new file mode 100644 index 00000000..0dd1608e --- /dev/null +++ b/app/src/test/resources/import-fixtures/markdown/obsidian-vault/Attachments/image.png @@ -0,0 +1,2 @@ +‰PNG + diff --git a/app/src/test/resources/import-fixtures/markdown/obsidian-vault/Groceries.md b/app/src/test/resources/import-fixtures/markdown/obsidian-vault/Groceries.md new file mode 100644 index 00000000..d4ab7557 --- /dev/null +++ b/app/src/test/resources/import-fixtures/markdown/obsidian-vault/Groceries.md @@ -0,0 +1,8 @@ +--- +tags: + - home +created: 2026-10-14T22:30:00 +--- +- [x] Milk +- [ ] Eggs +- [ ] Хліб 🍞 diff --git a/app/src/test/resources/import-fixtures/markdown/obsidian-vault/Markor note.txt b/app/src/test/resources/import-fixtures/markdown/obsidian-vault/Markor note.txt new file mode 100644 index 00000000..429cad58 --- /dev/null +++ b/app/src/test/resources/import-fixtures/markdown/obsidian-vault/Markor note.txt @@ -0,0 +1,4 @@ +Plain text note from Markor. +Second line of the same paragraph. + +New paragraph. diff --git a/app/src/test/resources/import-fixtures/markdown/obsidian-vault/Work/Meeting notes.md b/app/src/test/resources/import-fixtures/markdown/obsidian-vault/Work/Meeting notes.md new file mode 100644 index 00000000..a53ccd7e --- /dev/null +++ b/app/src/test/resources/import-fixtures/markdown/obsidian-vault/Work/Meeting notes.md @@ -0,0 +1,22 @@ +--- +title: Meeting notes +tags: [work, important] +aliases: [Weekly sync] +--- +# Weekly sync + +## Agenda + +- Release 2.2 +- Translations + - nested detail + +1. Review PRs +2. Ship + +> [!note] Decisions +> Decisions are final. + +--- + +Linked to [[Ідеї 💡]] and [[Groceries|the shopping list]]. #followup diff --git "a/app/src/test/resources/import-fixtures/markdown/obsidian-vault/\320\206\320\264\320\265\321\227 \360\237\222\241.md" "b/app/src/test/resources/import-fixtures/markdown/obsidian-vault/\320\206\320\264\320\265\321\227 \360\237\222\241.md" new file mode 100644 index 00000000..d916b720 --- /dev/null +++ "b/app/src/test/resources/import-fixtures/markdown/obsidian-vault/\320\206\320\264\320\265\321\227 \360\237\222\241.md" @@ -0,0 +1,3 @@ +Перша думка. + +Друга думка з **жирним** і `кодом`. diff --git a/app/src/test/resources/import-fixtures/notesnook/README.md b/app/src/test/resources/import-fixtures/notesnook/README.md new file mode 100644 index 00000000..f69d178a --- /dev/null +++ b/app/src/test/resources/import-fixtures/notesnook/README.md @@ -0,0 +1,14 @@ +Real exports from the Notesnook web app (offline mode, no account), synthetic notes only: +Settings → Backup & export → "Export all notes" as Markdown (`notes-md.zip`), Markdown + +Frontmatter (`notes-md-frontmatter.zip`) and Plain text (`notes-txt.zip`), plus "Create backup now" +without attachments (`backup.nnbackupz`, which Encly must refuse with guidance). + +- "Groceries": checklist Milk (checked), Eggs, "Хліб 🍞"; tag home. +- "Meeting notes" in notebook Work (folder `Work/`): H1, H2, bullets, numbered, quote, rule; tags work, important. +- "Ідеї 💡": two paragraphs. +- "Pinned reminder": `pinned: true`. +- "Archived recipe": in `_archive/`, `archived: true`. +- "Old draft" was moved to the trash; Notesnook does not export trashed notes. + +Dates in the frontmatter follow the app's date-format setting (here `14-10-2026 10:48 PM`), not ISO. +List items are separated by blank lines. File names replace spaces with `-`. diff --git a/app/src/test/resources/import-fixtures/notesnook/backup.nnbackupz b/app/src/test/resources/import-fixtures/notesnook/backup.nnbackupz new file mode 100644 index 0000000000000000000000000000000000000000..1a887279b882db749b95b5b64c30ca19176470c7 GIT binary patch literal 3421 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 Iogx76AAmCdBme*a literal 0 HcmV?d00001 diff --git a/app/src/test/resources/import-fixtures/notesnook/notes-md-frontmatter.zip b/app/src/test/resources/import-fixtures/notesnook/notes-md-frontmatter.zip new file mode 100644 index 0000000000000000000000000000000000000000..09e5dd4d7e937f25c7caeff4cc24a1216bce41d5 GIT binary patch literal 2056 zcmWIWW@gc4;Nak3VAR}}LpT{&EAE`niUndW1_iKE zMg|Fn2^xFei^SYzgwYPZi|&K?m#UjEg z`s-{AGBCMlYI4EU$iOJrz+{7wVK~Q!r&_um2UYVKC+U0o=x+-;apJu1(@Q{;9tDLp zNS`~pbhSXN;OYmnXHMo#(>|iLW7%@?!#BjI#oeeayw%#vCI9UExyO%~7z$r8_SZl? zvk*!Lz`ZJp=2cOkSHts*vh{saQ%f@Q(slFlOAvmZ1@vzhnx7A7{$mye`VmGqsFwT! z`MI|5pw|%x0hjlGMUOsv#dC5)#}fsPIB_Qt8?VV*Zl)cdnYgU<=KkpqPIez(RrTSN zrTa3m)FTg7l4hAL$+^V0B_wh3*SWbz3QC;cJITK@{POrC18RtH z)7qk=CgzJCd@Q==^ra&`a?4wR9b9%MgzR0>gp?c~5UzqJ4*EZ~h z1{OafgnY`r*L_rv^23t?&G5iqRwp@mn0te8G1ykNA!mqJMp zA32Tex=@yQx^=Dia_yQK_L&QX!ixg4l037#YLYsqHZAO2x3F>D#Kws;n-(@s?Of{A zpdX&;$QAc<(vdGo+N^r9v9Wr3sjMQ0cka+Tvwy?aq{B!1xTQt(a(3RRNj%>gD{l5e zQ=e3338<{rBSsF&A3X@r+e(7Y@S^m2S+QF2CRS*pGxgig^d zN=?ozNQL?S3ee{V(R{zaeYzURw=jCcc57)6zxM1#u0sX_?H~XC7jciRo-{>i#@VC3 zA9$a5@bR+fT<_kuuRFN?octfVw6^g4Ss6Pb>aO;S8J#V?$apg(=Crd-;2cY~d520C zNnEN~_M5j>b9d~&1f}ik3M$ui9yoKL(aik8t$TOfes!(x-Qx9G`2(|dpYehMs4o{_ z_|_TCw?aVQ24v>trGmVeo0*rAS_JdtJD>;OqIt5wQqT+>#ZdYISD7iylT3yJt{4B+ z9!*~BKFL-0nS9Gz9n~3*%-I~9H~-gD1}2zL?C4GZ@3etlQ#?!C3)pE>!r3x&N` zo$^z~^5mRn+{KrQ9~B+7dv)J#-vXl=bC=3IuMUoa2j{tWOf^W~)03=gyG{86WBZpI zY1vSJ&4K%mk%@sBQN)2thW}8&!{7^4_5VLe=@LeeAaa4n$RNW2D?4Ci3Di(96&k_{ zDG-S%3y^o`lh`you~8P|g6QEtso>FkFS1Pe6`=r9oJlWMn~e z3@9HVI|f%C3h-uQ(}C*MWzdD2!U|=<=%dg$cnGx!sE-|_WEq%bU;q{B0p6@^Fpt1! G1_l6fl(#bg literal 0 HcmV?d00001 diff --git a/app/src/test/resources/import-fixtures/notesnook/notes-md.zip b/app/src/test/resources/import-fixtures/notesnook/notes-md.zip new file mode 100644 index 0000000000000000000000000000000000000000..fd40c08fd3769d85f732de886be9a36b794490df GIT binary patch literal 1719 zcma*meMl2=7zgm*X`5>rnK`FuV1FpHcCNsrlF%qqTbolm>kpUMJZh$MTVn;8NVc#9 z2@|X|GAqhLvG)SA6eM4!`GzSK$$BHwQVc{v?S9)mdfX}+jOT8= ziO{mgYAxbCe9%UeV9wXkW<7m0GB2CjyBrCQzk8jdWu9Ub>b#Udf#|%f>8F3jdje*h zs`djuPfIr{I+N98=N|Hlqn|2iD4{00?H$4gl7o_rfq~MjF>j?}tan_{MsS{sK zCIV4k3$0NsVkJC}D{E^36vSDw?+F4TnfWzGcaJnQ@-uSzkuXdrBA^%%U*M zEVfE|?ulFDeB=A(Pqr1;A8RQn3LO@&6%XwQxzIxUSx=R}sH~8xO{??oRL$(nj9w8s zSvOl3qnlopc(yWWE-da#4HZ_Q{Z>{GHF^8~h=ZsWd(B;*A6`jVY||s@qxQRH0baFH zpWkNW5ayn^%(iNs*0F#8&5ptBE8Y`cp8^A#3fXNhxosW8+uE1ey7x@)!`?@ImE^?L z@@sH&9L%!KyoHTx0u*>*m>YhXg7A4-`qN%(R+p__dTV{b#aR7X=^NXO^znh*r^BYK z;#M0etx}9VYy93S+$Io23Iq+_X_3(xxy_A1R9UGvvOLe7iDjknZsI7OIG7P>rvOEb7wLPNmqr(H$me!nJ$NFS$&K*3bALG>M4F($H^7Mvm+6=rO3~ijpGj3TG z?fh@qIV!epa`8m%_AV!`yW6fzA{`DfcBRTX5-C}Gl6`%|5XQaoPZa|yd=W=@AkU>n z;zBI24o2&gGO2A6<~ w`C9=rN}*8BE)z2qZ9~X5ts=527svr#{d8T literal 0 HcmV?d00001 diff --git a/app/src/test/resources/import-fixtures/notesnook/notes-txt.zip b/app/src/test/resources/import-fixtures/notesnook/notes-txt.zip new file mode 100644 index 0000000000000000000000000000000000000000..e6990ba0d90e5fe251a514e7ae6d739ab45bb28a GIT binary patch literal 1711 zcma*mYe-XJ7zglon@*i*j?{HAuu$7_$0?bSm$a+tnyz&9#Y>_mTj|s}he4Sn7D!|i zX_=JW)Gx~DVudDY1l}(hW>B~WT9#=S!wSmIvz>RI&MZ1G{(HAG_ItMHuaxub2;}JK zi0qo;k`d!_K|v^5o0Cpz)ntBvu282+bVBxpFZ&NBQx5_1Hs8xZOVAl{%d~saD-O;m z#kEm5=gYd{vGit?5aYlJTCW8UXn!!Li{{%-d1vA2`1)xC%7H5GofWP(FV z!O`YLlPAZyH7&ym6*t^4RB!59<=(vUwom!37iYbU62nmWL}m7yeZrCcCT`}Q(5h(< zU;ohxAHMNhW^qWF@9DfxKlh)PCR7x-Mt+^yTvJRBY_u6Xj5WBB8hqfyz@34+gO$X` zD`&4-{~QxFZ!2r&+!dZOm>Fl_Ox{vBHxKC3x=R~Gb^b>#M5O<4DZ|d5dn?P2>~HVS*?bpDlfETgKy+|niye24Q5fjC~9h&HvNEFPs%n~ zGK0{P>FQk4s(wa^+gR$F=t@6O;xya~TMp-ZX>4DIRIb=1j#czYcZ`{|-JLSx=`L}+ zGzKEBM>5DmqV!tQkH-1fAh)oPiz%d1t6zmZ*Px0hA^1~fZ8qY+@TV8#Ps+}hI#0IWLpsyt+L8ET*R9%A=Ch0E=WP$ zH8%&h-r&Hie^io#;1{-BxH5LNf}jMjdl)3N0ohanEO/…` plain copies and an `SN|UserPreferences` item. diff --git a/app/src/test/resources/import-fixtures/standard-notes/Standard Notes Backup - decrypted.zip b/app/src/test/resources/import-fixtures/standard-notes/Standard Notes Backup - decrypted.zip new file mode 100644 index 0000000000000000000000000000000000000000..ec89c4946fdf64f225dd44c666a45d238d8e8983 GIT binary patch literal 4891 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 Date: Thu, 15 Oct 2026 08:54:00 +0300 Subject: [PATCH 09/18] feat(editor): add writing comfort tools Comfortable reading density, a slash command picker for empty paragraphs, Markdown-aware smart paste (one undo step, paragraphs kept), block duplication, find in note, and optional templates for new notes. The stored note format is unchanged; opening a note never rewrites it. Refs: #64 --- CHANGELOG.md | 14 +- .../data/importer/MarkdownBlockMapper.kt | 51 ++- .../encly/domain/model/NoteLineSpacing.kt | 8 +- .../components/editNote/NoteBottomBar.kt | 17 +- .../designsystem/CommandMenuItem.kt | 94 ++++++ .../encly/presentation/designsystem/Editor.kt | 8 +- .../presentation/designsystem/NoteSpacing.kt | 50 +++ .../presentation/designsystem/TextFields.kt | 19 +- .../dialogs/EditNoteBottomSheet.kt | 38 ++- .../dialogs/blocks/ActionBlockDialog.kt | 1 + .../dialogs/blocks/BlockEditRows.kt | 7 +- .../encly/presentation/editor/BlockActions.kt | 39 +++ .../presentation/editor/BlockActionsImpl.kt | 24 ++ .../presentation/editor/EditorBlocksHost.kt | 25 +- .../presentation/editor/FindHighlights.kt | 20 ++ .../presentation/editor/blocks/BlockInput.kt | 15 +- .../editor/blocks/FieldHighlight.kt | 66 ++++ .../presentation/editor/blocks/HBlock.kt | 3 + .../presentation/editor/blocks/ListBlock.kt | 27 +- .../presentation/editor/blocks/QuoteBlock.kt | 14 +- .../editor/blocks/SlashMenuPopup.kt | 124 ++++++++ .../presentation/editor/blocks/TextBlock.kt | 10 +- .../editor/blocks/TextBlockKeys.kt | 24 +- .../editor/state/BlockDuplicate.kt | 42 +++ .../presentation/editor/state/BlockEditing.kt | 34 +- .../editor/state/BlockEditorState.kt | 15 + .../presentation/editor/state/NoteFind.kt | 115 +++++++ .../editor/state/NoteTemplates.kt | 122 ++++++++ .../presentation/editor/state/PasteEditing.kt | 90 ++++++ .../presentation/editor/state/Shortcut.kt | 32 -- .../editor/state/SlashCommands.kt | 146 +++++++++ .../presentation/editor/state/SmartPaste.kt | 49 +++ .../screen/editnote/EditNoteScreen.kt | 60 ++-- .../screen/editnote/EditorEffects.kt | 60 ++++ .../screen/editnote/EditorFindBar.kt | 100 ++++++ .../screen/editnote/EditorSheets.kt | 1 + .../screen/editnote/TemplateChooser.kt | 48 +++ .../viewmodel/EditNoteViewModel.kt | 119 ++++++- app/src/main/res/values-de/strings.xml | 27 ++ app/src/main/res/values-es/strings.xml | 27 ++ app/src/main/res/values-fr/strings.xml | 27 ++ app/src/main/res/values-it/strings.xml | 27 ++ app/src/main/res/values-nl/strings.xml | 27 ++ app/src/main/res/values-pl/strings.xml | 27 ++ app/src/main/res/values-pt/strings.xml | 27 ++ app/src/main/res/values-uk/strings.xml | 27 ++ app/src/main/res/values/strings.xml | 27 ++ .../local/SettingsLocalDataSourceTest.kt | 19 ++ .../data/importer/MarkdownBlockMapperTest.kt | 36 +++ .../editor/state/BlockActionsTest.kt | 186 +++++++++++ .../editor/state/BlockEditingTest.kt | 14 +- .../presentation/editor/state/NoteFindTest.kt | 151 +++++++++ .../editor/state/NoteJsonGoldenTest.kt | 146 +++++++++ .../editor/state/NoteTemplatesTest.kt | 106 +++++++ .../editor/state/SlashCommandsTest.kt | 221 +++++++++++++ .../editor/state/SmartPasteTest.kt | 229 ++++++++++++++ .../viewmodel/EditNoteViewModelComfortTest.kt | 295 ++++++++++++++++++ .../encly/ui/screens/EditorWritingTest.kt | 60 ++++ docs/architecture.md | 10 + 59 files changed, 3291 insertions(+), 156 deletions(-) create mode 100644 app/src/main/java/com/pasich/encly/presentation/designsystem/CommandMenuItem.kt create mode 100644 app/src/main/java/com/pasich/encly/presentation/designsystem/NoteSpacing.kt create mode 100644 app/src/main/java/com/pasich/encly/presentation/editor/FindHighlights.kt create mode 100644 app/src/main/java/com/pasich/encly/presentation/editor/blocks/FieldHighlight.kt create mode 100644 app/src/main/java/com/pasich/encly/presentation/editor/blocks/SlashMenuPopup.kt create mode 100644 app/src/main/java/com/pasich/encly/presentation/editor/state/BlockDuplicate.kt create mode 100644 app/src/main/java/com/pasich/encly/presentation/editor/state/NoteFind.kt create mode 100644 app/src/main/java/com/pasich/encly/presentation/editor/state/NoteTemplates.kt create mode 100644 app/src/main/java/com/pasich/encly/presentation/editor/state/PasteEditing.kt create mode 100644 app/src/main/java/com/pasich/encly/presentation/editor/state/SlashCommands.kt create mode 100644 app/src/main/java/com/pasich/encly/presentation/editor/state/SmartPaste.kt create mode 100644 app/src/main/java/com/pasich/encly/presentation/screen/editnote/EditorFindBar.kt create mode 100644 app/src/main/java/com/pasich/encly/presentation/screen/editnote/TemplateChooser.kt create mode 100644 app/src/test/java/com/pasich/encly/presentation/editor/state/BlockActionsTest.kt create mode 100644 app/src/test/java/com/pasich/encly/presentation/editor/state/NoteFindTest.kt create mode 100644 app/src/test/java/com/pasich/encly/presentation/editor/state/NoteJsonGoldenTest.kt create mode 100644 app/src/test/java/com/pasich/encly/presentation/editor/state/NoteTemplatesTest.kt create mode 100644 app/src/test/java/com/pasich/encly/presentation/editor/state/SlashCommandsTest.kt create mode 100644 app/src/test/java/com/pasich/encly/presentation/editor/state/SmartPasteTest.kt create mode 100644 app/src/test/java/com/pasich/encly/presentation/viewmodel/EditNoteViewModelComfortTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 6f1fdd26..221f5e75 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,7 +16,19 @@ IzzyOnDroid) and used as the GitHub Release notes. - 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. -- Compact line spacing for notes, with a persistent editor setting; Default preserves the existing layout. +- 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. + 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. 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 index 147f0eb6..700ab3c0 100644 --- a/app/src/main/java/com/pasich/encly/data/importer/MarkdownBlockMapper.kt +++ b/app/src/main/java/com/pasich/encly/data/importer/MarkdownBlockMapper.kt @@ -25,6 +25,7 @@ object MarkdownBlockMapper { private val WIKI_LINK = Regex("""!?\[\[([^\]|\n]+)(?:\|([^\]\n]+))?]]""") private val BULLET = Regex("""^[ \t]*[-*+][ \t]+(.*)$""") private val NUMBER = Regex("""^[ \t]*\d{1,9}[.)][ \t]+(.*)$""") + private val PASTED_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}(```|~~~).*$""") @@ -33,6 +34,18 @@ object MarkdownBlockMapper { 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 a number only starts a list when it has up to three digits (so + * "2024. Report" stays text). [claim] may take a line whole as a block of its own (a lone web + * address); it runs before every other rule. [toBlocks] is unchanged. + */ + internal fun pasteBlocks(text: String, claim: (String) -> Block? = { null }): List = + Builder(PasteRules(claim)).apply { text.lines().forEach(::line) }.finish() + + private class PasteRules(val claim: (String) -> Block?) + /** [text] with HTML removed outside code spans. */ internal fun inline(text: String): String { val linked = AUTOLINK.replace(text, "$1") @@ -50,7 +63,7 @@ object MarkdownBlockMapper { WIKI_LINK.replace(text) { it.groupValues[2].ifEmpty { it.groupValues[1] } }, ) - private class Builder { + private class Builder(private val paste: PasteRules? = null) { private val blocks = ArrayList() private val paragraph = ArrayList() private val quote = ArrayList() @@ -62,13 +75,17 @@ object MarkdownBlockMapper { fun line(raw: String) { val fence = FENCE.matches(raw) when { - inFence && fence -> closeFence() + inFence && fence -> { + if (paste != null) code += raw + closeFence() + } inFence -> code += raw fence -> { flushAll() inFence = true + if (paste != null) code += raw } raw.isBlank() -> { @@ -92,9 +109,15 @@ object MarkdownBlockMapper { val check = CHECK.matchEntire(raw) val glyph = GLYPH_ITEM.matchEntire(raw) val bullet = BULLET.matchEntire(raw) - val number = NUMBER.matchEntire(raw) + val number = (if (paste != null) PASTED_NUMBER else 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() @@ -103,7 +126,7 @@ object MarkdownBlockMapper { heading != null -> { flushAll() val level = heading.groupValues[1].length - blocks += ImportBlocks.heading(inline(heading.groupValues[2]), level) + blocks += ImportBlocks.heading(readable(heading.groupValues[2]), level) } check != null -> item(BlockType.LIST_CHECK, check.groupValues[2], check.groupValues[1] != " ") @@ -117,29 +140,41 @@ object MarkdownBlockMapper { quoted != null -> { flushParagraph() flushList() - quote += inline(CALLOUT.replaceFirst(quoted.groupValues[1], "")) + 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 + " " + inline(raw.trim())).trim()) + items += last.copy(value = (last.value + " " + readable(raw.trim())).trim()) } else -> { flushList() flushQuote() - paragraph += inline(raw.trimEnd()) + 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 = inline(text).trim(), isCheck = checked) + items += ItemListBlock(value = readable(text).trim(), isCheck = checked) } private fun closeFence() { 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 index e79a9881..e5557b6e 100644 --- a/app/src/main/java/com/pasich/encly/domain/model/NoteLineSpacing.kt +++ b/app/src/main/java/com/pasich/encly/domain/model/NoteLineSpacing.kt @@ -1,7 +1,13 @@ package com.pasich.encly.domain.model -/** Editor text spacing, stored by ordinal. Append new choices without reordering these values. */ +/** + * 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/presentation/components/editNote/NoteBottomBar.kt b/app/src/main/java/com/pasich/encly/presentation/components/editNote/NoteBottomBar.kt index 276fe2fd..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 @@ -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/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 fce991b4..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,8 +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(compactText: Boolean = false): Modifier = - padding(horizontal = BlockBleed, vertical = if (compactText) 0.dp else BlockInset) +fun Modifier.editorBlockFrame(verticalInset: Dp? = null): Modifier = + padding(horizontal = BlockBleed, vertical = verticalInset ?: BlockInset) /** A thin vertical rule between groups of toolbar buttons. */ @Composable @@ -119,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 ad440de4..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 @@ -21,6 +21,7 @@ import com.pasich.encly.presentation.viewmodel.enclyViewModel enum class EditNoteBottomSheetAction { CLOSE_NO_SAVE, DUPLICATE, + FIND, TRASH, } @@ -47,17 +48,16 @@ fun EditNoteBottomSheet( onSizeChange = { newSize -> viewModel.updateFontSize(newSize) }, ) SectionOverline(stringResource(R.string.note_line_spacing_title)) - EnclyRadioRow( - title = stringResource(R.string.note_line_spacing_default), - selected = lineSpacing == NoteLineSpacing.DEFAULT, - onClick = { viewModel.updateNoteLineSpacing(NoteLineSpacing.DEFAULT) }, - ) - EnclyRadioRow( - title = stringResource(R.string.note_line_spacing_compact), - selected = lineSpacing == NoteLineSpacing.COMPACT, - onClick = { viewModel.updateNoteLineSpacing(NoteLineSpacing.COMPACT) }, - ) + 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, @@ -87,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/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/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 752a4880..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 @@ -24,9 +24,9 @@ import androidx.compose.ui.semantics.customActions import androidx.compose.ui.semantics.semantics import com.pasich.encly.R import com.pasich.encly.domain.enums.BottomSheetsOpenType -import com.pasich.encly.domain.model.NoteLineSpacing 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 @@ -44,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, ) @@ -52,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) }, @@ -75,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) }, ) } @@ -90,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 @@ -107,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( @@ -114,6 +123,7 @@ private class EditorBlockCallbacks( val onFocusLost: () -> Unit, val onOpenSheet: (BottomSheetsOpenType) -> Unit, val onMove: (up: Boolean) -> Unit, + val onDuplicate: () -> Unit, val onDelete: () -> Unit, ) @@ -152,8 +162,9 @@ private fun EditorBlock( } } .editorBlockFrame( - LocalNoteLineSpacing.current == NoteLineSpacing.COMPACT && - (block is Block.TextBlock || block is Block.ListBlock || block is Block.QuoteBlock), + LocalNoteLineSpacing.current.frameInset.takeIf { + block is Block.TextBlock || block is Block.ListBlock || block is Block.QuoteBlock + }, ), ) { BlockContent( @@ -172,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 @@ -183,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 0e3cd3ea..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,14 +39,14 @@ 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.domain.model.NoteLineSpacing 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 @@ -133,7 +133,7 @@ fun ListBlock( Column( modifier = modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy( - if (checklist || LocalNoteLineSpacing.current == NoteLineSpacing.COMPACT) 0.dp else EnclyTheme.spacing.xxs, + LocalNoteLineSpacing.current.listItemGap(checklist, EnclyTheme.spacing.xxs), ), ) { items.forEachIndexed { itemIndex, item -> @@ -155,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), @@ -165,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. @@ -177,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( @@ -203,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, @@ -283,12 +287,12 @@ private class ListEditor( @Composable private fun listTextStyle(): TextStyle { val fontStyles = rememberFontStyles() - val compact = LocalNoteLineSpacing.current == NoteLineSpacing.COMPACT + val spacing = LocalNoteLineSpacing.current return MaterialTheme.typography.bodyLarge.copy( color = MaterialTheme.colorScheme.onSurface, fontFamily = fontStyles.families.body, fontSize = fontStyles.sizes.list, - lineHeight = if (compact) fontStyles.sizes.list else MaterialTheme.typography.bodyLarge.lineHeight, + lineHeight = spacing.lineHeight(fontStyles.sizes.list, MaterialTheme.typography.bodyLarge.lineHeight), ) } @@ -328,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) { @@ -349,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 200cdb54..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 @@ -24,9 +24,9 @@ import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.TextStyle import com.pasich.encly.R -import com.pasich.encly.domain.model.NoteLineSpacing 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 @@ -53,14 +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 = if (LocalNoteLineSpacing.current == NoteLineSpacing.COMPACT) { - fontStyles.sizes.quote - } else { - EnclyTheme.typography.quote.lineHeight - }, + 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 @@ -86,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..f1ead231 --- /dev/null +++ b/app/src/main/java/com/pasich/encly/presentation/editor/blocks/SlashMenuPopup.kt @@ -0,0 +1,124 @@ +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.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.first + +/** + * 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) { + 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 localized label of every command, for the list and for matching the query. */ +@Composable +private fun slashLabels(): 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 61fec877..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 @@ -15,8 +15,8 @@ import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.input.KeyboardType -import com.pasich.encly.domain.model.NoteLineSpacing 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 @@ -41,16 +41,17 @@ fun TextBlock( showPlaceholder: Boolean = true, ) { val fontStyles = rememberFontStyles() - val compact = LocalNoteLineSpacing.current == NoteLineSpacing.COMPACT + 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 = if (compact) fontStyles.sizes.textBlock else MaterialTheme.typography.bodyLarge.lineHeight, + 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, @@ -60,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..c9e268d0 --- /dev/null +++ b/app/src/main/java/com/pasich/encly/presentation/editor/state/PasteEditing.kt @@ -0,0 +1,90 @@ +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. 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) + val mapped = pastedBlocks(pasted) + return when { + mapped.isEmpty() -> { + // Nothing but blank lines: no content to paste. + selection.focusAt(index, caret = lineBreak.head.length) + lineBreak.head + lineBreak.tail + } + + 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.TextBlock(MutableStateFlow(tail)) + } else { + val joined = last.text.value + pasted.last().takeLastWhile { it.isWhitespace() } + caret = joined.length + rest = rest.dropLast(1) + Block.TextBlock(MutableStateFlow(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 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..59227ccd --- /dev/null +++ b/app/src/main/java/com/pasich/encly/presentation/editor/state/SmartPaste.kt @@ -0,0 +1,49 @@ +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. + */ +fun pastedBlocks(lines: List): List { + val text = trimBlankLines(lines).joinToString("\n", transform = ::asMarkdownMark) + return MarkdownBlockMapper.pasteBlocks(text, claim = ::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/screen/editnote/EditNoteScreen.kt b/app/src/main/java/com/pasich/encly/presentation/screen/editnote/EditNoteScreen.kt index a4a431cf..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,6 +29,7 @@ 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.input.key.onPreviewKeyEvent import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.platform.LocalSoftwareKeyboardController @@ -39,7 +41,6 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavBackStackEntry import androidx.navigation.NavHostController import com.pasich.encly.R -import com.pasich.encly.domain.model.NoteLineSpacing import com.pasich.encly.presentation.components.editNote.NoteBottomBar import com.pasich.encly.presentation.components.editNote.NoteOverline import com.pasich.encly.presentation.components.editNote.TitleField @@ -47,10 +48,13 @@ 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 @@ -98,15 +102,22 @@ fun EditNoteScreen( 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( @@ -154,21 +165,25 @@ private fun EditorScaffold( ) { 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) @@ -239,6 +254,8 @@ private fun EditorOverlays( EditNoteBottomSheetAction.DUPLICATE -> exits.duplicate() + EditNoteBottomSheetAction.FIND -> viewModel.openFind() + EditNoteBottomSheetAction.TRASH -> exits.trash() } }, @@ -254,11 +271,12 @@ private fun EditorContent( modifier: Modifier = Modifier, viewModel: EditNoteViewModel = enclyViewModel(), ) { - val compactText = LocalNoteLineSpacing.current == NoteLineSpacing.COMPACT - val headerGap = if (compactText) EnclyTheme.spacing.labelGap else 0.dp + 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 } } @@ -267,7 +285,7 @@ private fun EditorContent( LazyColumn( state = listState, // Default block frames and this gap give 14 dp between blocks (spec §4.4). - verticalArrangement = Arrangement.spacedBy(if (compactText) 0.dp else EnclyTheme.spacing.labelGap), + verticalArrangement = Arrangement.spacedBy(spacing.blockGap(EnclyTheme.spacing.labelGap)), contentPadding = PaddingValues(top = EnclyTheme.spacing.xxs), modifier = modifier.fillMaxSize(), ) { @@ -300,7 +318,13 @@ private fun EditorContent( ) } } - editorBlocks(host, viewModel.blocks, isLocked = lockEditor, editingLinkIds = editingLinkIds) + editorBlocks( + host, + viewModel.blocks, + isLocked = lockEditor, + editingLinkIds = editingLinkIds, + afterBlocks = if (templatesOffered) ({ TemplateChooser() }) else null, + ) } } } 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 f8f9750e..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,6 +17,13 @@ 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.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver @@ -23,6 +33,8 @@ 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 @@ -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 eaf4f388..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 @@ -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) 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/viewmodel/EditNoteViewModel.kt b/app/src/main/java/com/pasich/encly/presentation/viewmodel/EditNoteViewModel.kt index aba99c03..cbb8a8d5 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 @@ -24,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 @@ -49,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 @@ -129,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 @@ -173,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 = "") } } @@ -196,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) @@ -288,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). @@ -345,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) @@ -355,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) @@ -433,6 +495,55 @@ 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. + */ + val templatesOffered: StateFlow = combine( + persistence.state.map { it.note.title.isEmpty() }.distinctUntilChanged(), + editor.contentChanges.map { editor.isBlankNote() }.onStart { emit(editor.isBlankNote()) }, + simpleEdit, + _lockEditor, + ) { blankTitle, blankBody, simple, locked -> isNewNote && blankTitle && 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/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 225ebe58..9255a6db 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -151,6 +151,7 @@ Zeilenabstand Standard Kompakt + Großzügig Redaktionell Modern Technisch @@ -573,4 +574,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-es/strings.xml b/app/src/main/res/values-es/strings.xml index d2c8a110..4ac5509f 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -151,6 +151,7 @@ Interlineado Predeterminado Compacto + Amplio Editorial Moderno Técnico @@ -579,4 +580,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-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 4499adb1..509fb802 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -151,6 +151,7 @@ Espacement des lignes Par défaut Compact + Aéré Éditorial Moderne Technique @@ -579,4 +580,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-it/strings.xml b/app/src/main/res/values-it/strings.xml index 1fb03851..e9792ded 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -151,6 +151,7 @@ Interlinea Predefinita Compatta + Ampia Editoriale Moderno Tecnico @@ -579,4 +580,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-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index fe057181..e884ffbf 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -151,6 +151,7 @@ Regelafstand Standaard Compact + Ruim Redactioneel Modern Technisch @@ -573,4 +574,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-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 748da1fb..91a70f2e 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -151,6 +151,7 @@ Odstępy między wierszami Domyślne Kompaktowe + Przestronne Redakcyjny Nowoczesny Techniczny @@ -585,4 +586,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-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index 872de13c..b47a9e9d 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -151,6 +151,7 @@ Espaçamento entre linhas Padrão Compacto + Espaçoso Editorial Moderno Técnico @@ -579,4 +580,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-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index 7a76e963..4104551f 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -151,6 +151,7 @@ Міжряддя Звичайне Компактне + Просторе Редакційний Сучасний Технічний @@ -585,4 +586,30 @@ %1$d з %2$d підзавдань виконано %1$d з %2$d підзавдання виконано + Вставити блок + Знайти в нотатці + Знайти в цій нотатці + %1$d з %2$d + Збігів немає + Наступний збіг + Попередній збіг + Закрити пошук + Дублювати блок + Почати із шаблону + Порожня + Зустріч + Щоденник + Нотатки проєкту + Учасники + Порядок денний + Нотатки + Дії до виконання + Сьогодні + Вдячність + Завтра + Мета + Завдання + Рішення + Відкриті питання + Зробити diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c06761dd..1f22a96e 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -167,6 +167,7 @@ Line spacing Default Compact + Comfortable Editorial Modern Technical @@ -590,4 +591,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/test/java/com/pasich/encly/data/datasource/local/SettingsLocalDataSourceTest.kt b/app/src/test/java/com/pasich/encly/data/datasource/local/SettingsLocalDataSourceTest.kt index c1b1c54a..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 @@ -54,6 +54,25 @@ class SettingsLocalDataSourceTest { 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 fun appearanceRoundTrips() = runTest { source.setThemePalette(ThemePalette.GRAPHITE) diff --git a/app/src/test/java/com/pasich/encly/data/importer/MarkdownBlockMapperTest.kt b/app/src/test/java/com/pasich/encly/data/importer/MarkdownBlockMapperTest.kt index 5dbf557c..0b9cb977 100644 --- a/app/src/test/java/com/pasich/encly/data/importer/MarkdownBlockMapperTest.kt +++ b/app/src/test/java/com/pasich/encly/data/importer/MarkdownBlockMapperTest.kt @@ -62,4 +62,40 @@ class MarkdownBlockMapperTest { ) assertEquals(emptyList(), blocks("\n\n \n")) } + + // --- paste mode: the same line rules, nothing rewritten -------------------------------------------- + + private fun pasted(markdown: String) = describe(MarkdownBlockMapper.pasteBlocks(markdown)) + + @Test + fun aPasteReadsTheSameMarksAsAnImport() { + val markdown = "# A\n\nintro\nstill intro\n\n- [x] a\n- b\n1. c\n\n> q1\n> q2\n\n---" + assertEquals(blocks(markdown), pasted(markdown)) + } + + @Test + fun aPasteKeepsHtmlWikiLinksCalloutMarkersAndFenceLines() { + assertEquals( + listOf("text:use
here
& [[Note|alias]]"), + pasted("use
here
& [[Note|alias]]"), + ) + assertEquals(listOf("quote:[!note] Title\nbody"), pasted("> [!note] Title\n> body")) + assertEquals(listOf("text:```kotlin\nval x = 1\n```"), pasted("```kotlin\nval x = 1\n```")) + } + + @Test + fun aPasteNeedsAtMostThreeDigitsForANumberedList() { + assertEquals(listOf("text:2024. Report"), pasted("2024. Report")) + assertEquals(listOf("number:[ ] one"), pasted("12. one")) + // An import keeps its longer numbers as items. + assertEquals(listOf("number:[ ] Report"), blocks("2024. Report")) + } + + @Test + fun aPasteCanLetTheCallerClaimALineAsABlockOfItsOwn() { + val claimed = MarkdownBlockMapper.pasteBlocks("before\nhttps://x.org\nafter") { line -> + ImportBlocks.quote(line).takeIf { line.startsWith("https://") } + } + assertEquals(listOf("text:before", "quote:https://x.org", "text:after"), describe(claimed)) + } } diff --git a/app/src/test/java/com/pasich/encly/presentation/editor/state/BlockActionsTest.kt b/app/src/test/java/com/pasich/encly/presentation/editor/state/BlockActionsTest.kt new file mode 100644 index 00000000..6046451d --- /dev/null +++ b/app/src/test/java/com/pasich/encly/presentation/editor/state/BlockActionsTest.kt @@ -0,0 +1,186 @@ +package com.pasich.encly.presentation.editor.state + +import com.pasich.encly.domain.model.ItemListBlock +import com.pasich.encly.domain.model.LinkDataBlock +import com.pasich.encly.dynamicBlocks.Block +import com.pasich.encly.dynamicBlocks.BlockType +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** Duplicate, move, delete and convert on the block the user works on: each is one undo step. */ +class BlockActionsTest { + private val editor = BlockEditorState { 0L } + + private fun text(value: String) = Block.TextBlock(MutableStateFlow(value)) + + private fun list(vararg items: String) = + Block.ListBlock(MutableStateFlow(items.map { ItemListBlock(it) }), BlockType.LIST_CHECK) + + private fun texts() = editor.blocks.map { (it as Block.TextBlock).text.value } + + private fun lastFocus(): FocusRequest { + var request: FocusRequest? = null + runTest { request = editor.selection.requests.first() } + return request!! + } + + // --- duplicate -------------------------------------------------------------------------------- + + @Test + fun duplicateInsertsAnIndependentCopyAfterTheBlockAndFocusesIt() { + val a = text("a") + val b = text("b") + editor.load(listOf(a, b)) + + assertTrue(editor.duplicateBlockOf(a.id)) + + assertEquals(listOf("a", "a", "b"), texts()) + val copy = editor.blocks[1] as Block.TextBlock + assertNotEquals(a.id, copy.id) + copy.text.value = "changed" + assertEquals("a", a.text.value) + val focus = lastFocus() + assertEquals(copy.id, focus.blockId) + assertTrue(focus.cursorToEnd) + } + + @Test + fun duplicateIsOneUndoStepAndRedoBringsTheCopyBack() { + val a = text("a") + editor.load(listOf(a)) + + editor.duplicateBlockOf(a.id) + editor.undo() + assertEquals(listOf("a"), texts()) + assertFalse(editor.canUndo.value) + editor.redo() + assertEquals(listOf("a", "a"), texts()) + } + + @Test + fun aDuplicatedListSharesNeitherItsFlowNorItsItemIds() { + val original = list("milk", "eggs") + editor.load(listOf(original)) + + editor.duplicateBlockOf(original.id) + + val copy = editor.blocks[1] as Block.ListBlock + assertEquals(listOf("milk", "eggs"), copy.items.value.map { it.value }) + assertTrue(copy.items.value.map { it.id }.intersect(original.items.value.map { it.id }.toSet()).isEmpty()) + assertEquals(copy.blockType, original.blockType) + copy.items.value = copy.items.value.map { it.copy(value = "x", isCheck = true) } + assertEquals(listOf("milk", "eggs"), original.items.value.map { it.value }) + assertFalse(original.items.value.any { it.isCheck }) + } + + @Test + fun linksHeadingsQuotesAndSeparatorsDuplicateToo() { + val link = Block.LinkBlock(MutableStateFlow(LinkDataBlock(title = "t", url = "https://example.com"))) + val heading = Block.HBlock(MutableStateFlow("H"), BlockType.H3) + val quote = Block.QuoteBlock(MutableStateFlow("Q")) + val separator = Block.SeparatorBlock() + editor.load(listOf(link, heading, quote, separator)) + + listOf(link, heading, quote, separator).forEach { editor.duplicateBlockOf(it.id) } + + assertEquals(8, editor.blocks.size) + assertEquals( + "https://example.com", + (editor.blocks[1] as Block.LinkBlock).block.value.url, + ) + assertEquals(BlockType.H3, (editor.blocks[3] as Block.HBlock).blockType) + assertEquals("Q", (editor.blocks[5] as Block.QuoteBlock).text.value) + assertTrue(editor.blocks[7] is Block.SeparatorBlock) + assertEquals(8, editor.blocks.map { it.id }.toSet().size) + } + + @Test + fun duplicateInteractedActsOnTheBlockTheUserLastWorkedOn() { + val a = text("a") + val b = text("b") + editor.load(listOf(a, b)) + editor.selection.onInteraction(b.id) + + editor.duplicateInteracted() + + assertEquals(listOf("a", "b", "b"), texts()) + } + + @Test + fun duplicateOfABlockThatIsGoneDoesNothing() { + editor.load(listOf(text("a"))) + + assertFalse(editor.duplicateBlockOf("gone")) + assertFalse(editor.canUndo.value) + } + + // --- move, delete, convert ---------------------------------------------------------------------- + + @Test + fun moveUpAndDownKeepTheBlockFocusedAndEachIsOneUndoStep() { + val a = text("a") + val b = text("b") + val c = text("c") + editor.load(listOf(a, b, c)) + editor.selection.onInteraction(b.id) + + editor.moveInteracted(up = true) + assertEquals(listOf("b", "a", "c"), texts()) + assertEquals(b.id, lastFocus().blockId) + editor.moveInteracted(up = false) + assertEquals(listOf("a", "b", "c"), texts()) + editor.undo() + assertEquals(listOf("b", "a", "c"), texts()) + editor.undo() + assertEquals(listOf("a", "b", "c"), texts()) + } + + @Test + fun theFirstBlockCannotMoveUpAndTheLastCannotMoveDown() { + val a = text("a") + val b = text("b") + editor.load(listOf(a, b)) + editor.selection.onInteraction(a.id) + assertFalse(editor.canMoveInteracted(up = true)) + editor.selection.onInteraction(b.id) + assertFalse(editor.canMoveInteracted(up = false)) + } + + @Test + fun deleteIsOneUndoStepFocusesTheBlockAboveAndNeverLeavesTheNoteEmpty() { + val a = text("a") + val b = text("b") + editor.load(listOf(a, b)) + editor.selection.onInteraction(b.id) + + editor.removeInteracted() + + assertEquals(listOf("a"), texts()) + assertEquals(a.id, lastFocus().blockId) + assertFalse(editor.canRemoveInteracted()) + editor.undo() + assertEquals(listOf("a", "b"), texts()) + } + + @Test + fun convertKeepsTheTextAndTheCaretAndIsOneUndoStep() { + val block = text("Hello") + editor.load(listOf(block)) + editor.selection.onCaret(block.id, 3) + editor.selection.onInteraction(block.id) + + editor.applyTool(BlockType.H2) + + val heading = editor.blocks.single() as Block.HBlock + assertEquals("Hello", heading.text.value) + assertEquals(3, lastFocus().caret) + editor.undo() + assertEquals("Hello", (editor.blocks.single() as Block.TextBlock).text.value) + } +} diff --git a/app/src/test/java/com/pasich/encly/presentation/editor/state/BlockEditingTest.kt b/app/src/test/java/com/pasich/encly/presentation/editor/state/BlockEditingTest.kt index b999c4fe..12c822d0 100644 --- a/app/src/test/java/com/pasich/encly/presentation/editor/state/BlockEditingTest.kt +++ b/app/src/test/java/com/pasich/encly/presentation/editor/state/BlockEditingTest.kt @@ -108,14 +108,15 @@ class BlockEditingTest { // --- paste ---------------------------------------------------------------------------------- @Test - fun pastedLinesBecomeParagraphsAndTheTextAfterTheCursorFollowsTheLastOne() { + fun aBlankLineInAPasteStartsAParagraphAndASingleLineBreakStaysInsideIt() { val block = text("Start end") editor.load(listOf(block)) editor.breakLine(block.id, paste("Start end", 6, "one\n\ntwo\nthree ")) - assertEquals(listOf("Start one", "two", "three end"), contents()) - assertFocus(2, caret = "three ".length) + // Was three blocks before Smart Paste: "two" and "three" are one paragraph of two lines. + assertEquals(listOf("Start one", "two\nthree end"), contents()) + assertFocus(1, caret = "two\nthree ".length) editor.undo() assertEquals(listOf("Start end"), contents()) } @@ -140,14 +141,15 @@ class BlockEditingTest { } @Test - fun aPasteEndingInALineBreakGoesOnInANewParagraph() { + fun aTrailingLineBreakOfAPasteIsWhitespaceNotContent() { val block = text("Note") editor.load(listOf(block)) editor.breakLine(block.id, paste("Note", 4, " one\n")) - assertEquals(listOf("Note one", ""), contents()) - assertFocus(1, caret = null) + // Was a new empty paragraph before Smart Paste; a copied line's own line break is dropped. + assertEquals(listOf("Note one"), contents()) + assertFocus(0, caret = "Note one".length) } @Test diff --git a/app/src/test/java/com/pasich/encly/presentation/editor/state/NoteFindTest.kt b/app/src/test/java/com/pasich/encly/presentation/editor/state/NoteFindTest.kt new file mode 100644 index 00000000..62ee1508 --- /dev/null +++ b/app/src/test/java/com/pasich/encly/presentation/editor/state/NoteFindTest.kt @@ -0,0 +1,151 @@ +package com.pasich.encly.presentation.editor.state + +import com.pasich.encly.core.serialization.BlockConverter +import com.pasich.encly.domain.model.ItemListBlock +import com.pasich.encly.domain.model.LinkDataBlock +import com.pasich.encly.dynamicBlocks.Block +import com.pasich.encly.dynamicBlocks.BlockType +import kotlinx.coroutines.flow.MutableStateFlow +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** Find in this note: where it looks, how it moves, and that it never touches the note. */ +class NoteFindTest { + private val paragraph = Block.TextBlock(MutableStateFlow("The cat sat. The CAT slept.")) + private val heading = Block.HBlock(MutableStateFlow("Cats"), BlockType.H2) + private val quote = Block.QuoteBlock(MutableStateFlow("no match here")) + private val list = Block.ListBlock( + MutableStateFlow(listOf(ItemListBlock("feed the cat"), ItemListBlock("nothing"), ItemListBlock("cat toys"))), + BlockType.LIST_BULLET, + ) + private val link = Block.LinkBlock(MutableStateFlow(LinkDataBlock(title = "cat", url = "https://cat.example"))) + private val blocks = listOf(paragraph, heading, quote, list, link, Block.SeparatorBlock()) + + @Test + fun matchesAcrossParagraphsHeadingsAndListItemsInReadingOrderIgnoringCase() { + val found = findMatches(blocks, "cat") + + assertEquals( + listOf( + Triple(paragraph.id, null, 4..7), + Triple(paragraph.id, null, 17..20), + Triple(heading.id, null, 0..3), + Triple(list.id, list.items.value[0].id, 9..12), + Triple(list.id, list.items.value[2].id, 0..3), + ), + found.map { Triple(it.blockId, it.itemId, it.start..it.end) }, + ) + } + + @Test + fun matchesDoNotOverlapAndABlankQueryFindsNothing() { + val block = Block.TextBlock(MutableStateFlow("aaaa")) + + assertEquals(2, findMatches(listOf(block), "aa").size) + assertTrue(findMatches(listOf(block), "").isEmpty()) + assertTrue(findMatches(listOf(block), " ").isEmpty()) + } + + @Test + fun unicodeAndSurrogatePairsKeepTheirOffsets() { + val block = Block.TextBlock(MutableStateFlow("😀 İstanbul ÄÖ")) + + val found = findMatches(listOf(block), "äö").single() + + assertEquals("ÄÖ", block.text.value.substring(found.start, found.end)) + } + + @Test + fun nextAndPreviousWrapRoundAndTheFirstMatchIsCurrent() { + val find = NoteFind().apply { open() } + + find.search("cat", blocks) + assertEquals(0, find.state.value.current) + find.previous() + assertEquals(4, find.state.value.current) + find.next() + assertEquals(0, find.state.value.current) + find.next() + assertEquals(1, find.state.value.current) + } + + @Test + fun nothingFoundMeansNoCurrentMatchAndStepsAreHarmless() { + val find = NoteFind().apply { open() } + + find.search("zebra", blocks) + find.next() + find.previous() + + assertEquals(-1, find.state.value.current) + assertNull(find.state.value.currentMatch) + } + + @Test + fun refreshFollowsTheTextAndKeepsTheCurrentPlaceWhereItCan() { + val find = NoteFind().apply { open() } + find.search("cat", blocks) + find.next() + find.next() + + paragraph.text.value = "The cat sat." + find.refresh(blocks) + + assertEquals(4, find.state.value.matches.size) + assertEquals(2, find.state.value.current) + list.items.value = emptyList() + find.refresh(blocks) + assertEquals(2, find.state.value.matches.size) + assertEquals(1, find.state.value.current) + } + + @Test + fun closingForgetsTheQueryAndEveryMatch() { + val find = NoteFind().apply { open() } + find.search("cat", blocks) + + find.close() + + assertEquals(FindState(), find.state.value) + assertFalse(find.isOpen) + } + + @Test + fun matchesAreGroupedByFieldForDrawing() { + val find = NoteFind().apply { open() } + find.search("cat", blocks) + + assertEquals(2, find.state.value.byField.getValue(paragraph.id).size) + assertEquals(1, find.state.value.byField.getValue(fieldKey(list.id, list.items.value[0].id)).size) + } + + @Test + fun searchingNeverChangesTheNote() { + val before = BlockConverter.blocksToJson(blocks) + val editor = BlockEditorState { 0L } + editor.load(blocks) + + editor.find.open() + editor.find.search("cat", editor.blocks) + editor.find.next() + editor.find.refresh(editor.blocks) + editor.find.close() + + assertEquals(before, BlockConverter.blocksToJson(editor.blocks)) + assertFalse(editor.canUndo.value) + } + + @Test + fun loadingANoteClosesFind() { + val editor = BlockEditorState { 0L } + editor.find.open() + editor.find.search("x", editor.blocks) + + editor.load(emptyList()) + + assertEquals(FindState(), editor.find.state.value) + } +} diff --git a/app/src/test/java/com/pasich/encly/presentation/editor/state/NoteJsonGoldenTest.kt b/app/src/test/java/com/pasich/encly/presentation/editor/state/NoteJsonGoldenTest.kt new file mode 100644 index 00000000..e862aaca --- /dev/null +++ b/app/src/test/java/com/pasich/encly/presentation/editor/state/NoteJsonGoldenTest.kt @@ -0,0 +1,146 @@ +package com.pasich.encly.presentation.editor.state + +import com.pasich.encly.core.serialization.BlockConverter +import org.junit.Assert.assertEquals +import org.junit.Test + +/** + * The stored format is untouched by Writing Comfort: a note written by an earlier version + * (every block kind) opens, goes through the editor and the new features' read-only parts, and + * serializes to exactly the same bytes. Nothing here is saved unless the user changes the note. + */ +class NoteJsonGoldenTest { + @Test + fun theEarlierFormatIsWhatTheSerializerStillWrites() { + assertEquals(GOLDEN, BlockConverter.blocksToJson(BlockConverter.jsonToBlocks(GOLDEN))) + } + + @Test + fun openingTheNoteInTheEditorLeavesItsBytesAlone() { + val editor = BlockEditorState { 0L } + + editor.load(BlockConverter.jsonToBlocks(GOLDEN)) + + assertEquals(GOLDEN, BlockConverter.blocksToJson(editor.blocks)) + assertEquals(false, editor.canUndo.value) + } + + @Test + fun findSlashAndHistoryUsedWithoutAChangeLeaveTheBytesAlone() { + val editor = BlockEditorState { 0L } + editor.load(BlockConverter.jsonToBlocks(GOLDEN)) + + editor.find.open() + editor.find.search("e", editor.blocks) + editor.find.next() + editor.find.previous() + editor.find.close() + editor.slash.open(editor.blocks.first().id) + editor.slash.onText(editor.blocks.first().id, "/he") + editor.slash.dismiss() + editor.undo() + editor.redo() + + assertEquals(GOLDEN, BlockConverter.blocksToJson(editor.blocks)) + } + + @Test + fun aTemplateIsNotAppliedToANoteThatHasText() { + val editor = BlockEditorState { 0L } + editor.load(BlockConverter.jsonToBlocks(GOLDEN)) + + editor.applyTemplate(NoteTemplate.MEETING.blocks { "x" }) + + assertEquals(GOLDEN, BlockConverter.blocksToJson(editor.blocks)) + } + + @Test + fun aPasteOfASingleLineIsLeftToTheFieldAndTheBytesStayToo() { + val editor = BlockEditorState { 0L } + editor.load(BlockConverter.jsonToBlocks(GOLDEN)) + + editor.duplicateBlockOf("missing") + editor.removeInteracted() + editor.undo() + + assertEquals(GOLDEN, BlockConverter.blocksToJson(editor.blocks)) + } + + private companion object { + /** One of every block kind, as `Note.value` has been written since the block editor shipped. */ + val GOLDEN = """ + [ + { + "blockType": "TEXT", + "text": "A paragraph\nwith two lines" + }, + { + "blockType": "H1", + "text": "One" + }, + { + "blockType": "H2", + "text": "Two" + }, + { + "blockType": "H3", + "text": "Three" + }, + { + "blockType": "H4", + "text": "Four" + }, + { + "blockType": "QUOTE", + "text": "Quoted" + }, + { + "blockType": "LIST_CHECK", + "items": [ + { + "value": "open", + "isCheck": false + }, + { + "value": "done", + "isCheck": true + } + ] + }, + { + "blockType": "LIST_BULLET", + "items": [ + { + "value": "bullet", + "isCheck": false + } + ] + }, + { + "blockType": "LIST_NUMBER", + "items": [ + { + "value": "first", + "isCheck": false + }, + { + "value": "second", + "isCheck": false + } + ] + }, + { + "blockType": "LINK", + "block": { + "url": "https://example.com/a", + "title": "example.com", + "imageUrl": "" + } + }, + { + "blockType": "SEPARATOR" + } + ] + """.trimIndent() + } +} diff --git a/app/src/test/java/com/pasich/encly/presentation/editor/state/NoteTemplatesTest.kt b/app/src/test/java/com/pasich/encly/presentation/editor/state/NoteTemplatesTest.kt new file mode 100644 index 00000000..7ff47bba --- /dev/null +++ b/app/src/test/java/com/pasich/encly/presentation/editor/state/NoteTemplatesTest.kt @@ -0,0 +1,106 @@ +package com.pasich.encly.presentation.editor.state + +import com.pasich.encly.core.serialization.BlockConverter +import com.pasich.encly.dynamicBlocks.Block +import com.pasich.encly.dynamicBlocks.BlockType +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** The ready outlines for a new note: ordinary blocks that the existing serializer stores as usual. */ +class NoteTemplatesTest { + private val editor = BlockEditorState { 0L } + + /** A stand-in for the UI's string lookup: every resource id reads as its own text. */ + private fun resolve(id: Int) = "text#$id" + + @Test + fun blankIsWhatANewNoteAlreadyIsAndMakesNoBlocks() { + assertTrue(NoteTemplate.BLANK.blocks(::resolve).isEmpty()) + assertFalse(editor.applyTemplate(NoteTemplate.BLANK.blocks(::resolve))) + assertTrue(editor.isBlankNote()) + } + + @Test + fun everyTemplateIsHeadingsParagraphsAndListsOfTheExistingKinds() { + val allowed = setOf( + BlockType.H2, + BlockType.TEXT, + BlockType.LIST_CHECK, + BlockType.LIST_BULLET, + BlockType.LIST_NUMBER, + ) + NoteTemplate.entries.filter { it != NoteTemplate.BLANK }.forEach { template -> + val blocks = template.blocks(::resolve) + assertTrue(template.name, blocks.isNotEmpty()) + assertTrue(template.name, blocks.first() is Block.HBlock) + blocks.forEach { block -> + val type = when (block) { + is Block.HBlock -> block.blockType + is Block.ListBlock -> block.blockType + is Block.TextBlock -> BlockType.TEXT + else -> error("${template.name} uses ${block::class.simpleName}") + } + assertTrue("${template.name}: $type", type in allowed) + } + // Headings carry the localized text; every heading is filled in. + assertTrue(blocks.filterIsInstance().all { it.text.value.startsWith("text#") }) + } + } + + @Test + fun everyTemplateSerializesWithTheExistingSerializerAndReadsBackTheSame() { + NoteTemplate.entries.filter { it != NoteTemplate.BLANK }.forEach { template -> + val json = BlockConverter.blocksToJson(template.blocks(::resolve)) + + val back = BlockConverter.jsonToBlocks(json) + + assertEquals(template.name, json, BlockConverter.blocksToJson(back)) + // Empty paragraphs and lists are not stored; the headings are. + val headings = template.parts.filterIsInstance().size + assertEquals(template.name, headings, back.filterIsInstance().size) + } + } + + @Test + fun aTemplateFillsABlankNoteInOneUndoStepAndUndoGivesTheBlankNoteBack() { + val blocks = NoteTemplate.MEETING.blocks(::resolve) + + assertTrue(editor.applyTemplate(blocks)) + + assertEquals(blocks.size, editor.blocks.size) + assertFalse(editor.isBlankNote()) + editor.undo() + assertTrue(editor.isBlankNote()) + assertFalse(editor.canUndo.value) + editor.redo() + assertEquals(blocks.size, editor.blocks.size) + } + + @Test + fun aTemplateNeverReplacesWhatIsAlreadyWritten() { + editor.load(listOf(Block.TextBlock(MutableStateFlow("my note")))) + + assertFalse(editor.applyTemplate(NoteTemplate.JOURNAL.blocks(::resolve))) + + assertEquals("my note", (editor.blocks.single() as Block.TextBlock).text.value) + assertFalse(editor.canUndo.value) + } + + @Test + fun theFirstEmptyFieldAfterTheFirstHeadingTakesFocus() { + val blocks = NoteTemplate.PROJECT.blocks(::resolve) + editor.applyTemplate(blocks) + + var request: FocusRequest? = null + runTest { request = editor.selection.requests.first() } + + assertNotNull(request) + assertEquals(blocks[1].id, request?.blockId) + } +} diff --git a/app/src/test/java/com/pasich/encly/presentation/editor/state/SlashCommandsTest.kt b/app/src/test/java/com/pasich/encly/presentation/editor/state/SlashCommandsTest.kt new file mode 100644 index 00000000..89a49b2b --- /dev/null +++ b/app/src/test/java/com/pasich/encly/presentation/editor/state/SlashCommandsTest.kt @@ -0,0 +1,221 @@ +package com.pasich.encly.presentation.editor.state + +import com.pasich.encly.dynamicBlocks.Block +import com.pasich.encly.dynamicBlocks.BlockType +import kotlinx.coroutines.flow.MutableStateFlow +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Test + +/** The slash picker: which commands a query leaves, when it opens and closes, and what picking does. */ +class SlashCommandsTest { + private val editor = BlockEditorState { 0L } + private val labels = mapOf( + SlashCommand.TEXT to "Text", + SlashCommand.H1 to "Heading 1", + SlashCommand.H2 to "Heading 2", + SlashCommand.H3 to "Heading 3", + SlashCommand.H4 to "Heading 4", + SlashCommand.CHECKLIST to "Checklist", + SlashCommand.BULLETS to "Bulleted list", + SlashCommand.NUMBERED to "Numbered list", + SlashCommand.QUOTE to "Quote", + SlashCommand.LINK to "Link", + SlashCommand.DIVIDER to "Separator", + ) + + // --- filtering -------------------------------------------------------------------------------- + + @Test + fun anEmptyQueryOffersEveryBlockKindInOrder() { + assertEquals( + listOf( + BlockType.TEXT, BlockType.H1, BlockType.H2, BlockType.H3, BlockType.H4, BlockType.LIST_CHECK, + BlockType.LIST_BULLET, BlockType.LIST_NUMBER, BlockType.QUOTE, BlockType.LINK, BlockType.SEPARATOR, + ), + slashMatches("", labels).map { it.type }, + ) + } + + @Test + fun aQueryMatchesTheStartOfAWordOfTheLabelOrAKeywordIgnoringCase() { + assertEquals( + listOf(SlashCommand.H1, SlashCommand.H2, SlashCommand.H3, SlashCommand.H4), + slashMatches("HEAD", labels), + ) + assertEquals(listOf(SlashCommand.H2), slashMatches("h2", labels)) + assertEquals(listOf(SlashCommand.BULLETS, SlashCommand.NUMBERED), slashMatches("list", labels)) + assertEquals(listOf(SlashCommand.DIVIDER), slashMatches("div", labels)) + assertEquals(listOf(SlashCommand.CHECKLIST), slashMatches("todo", labels)) + assertEquals( + listOf(SlashCommand.BULLETS, SlashCommand.NUMBERED, SlashCommand.LINK, SlashCommand.DIVIDER), + slashMatches("li", labels), + ) + assertTrue(slashMatches("zzz", labels).isEmpty()) + } + + @Test + fun matchingFollowsTheLocalizedLabelsToo() { + val ukrainian = labels + (SlashCommand.QUOTE to "Цитата") + assertEquals(listOf(SlashCommand.QUOTE), slashMatches("цит", ukrainian)) + } + + // --- opening and closing ---------------------------------------------------------------------- + + @Test + fun theMenuStaysHiddenUntilTheSlashIsCommitted() { + val picker = SlashPicker().apply { setLabels(labels) } + + picker.open("b") + assertFalse(picker.menu.value!!.isShown) + picker.commit() + + assertTrue(picker.menu.value!!.isShown) + } + + @Test + fun theQueryFollowsTheTextAndASpaceOrANonMatchingQueryClosesIt() { + val picker = SlashPicker().apply { setLabels(labels) } + picker.open("b") + picker.commit() + + picker.onText("b", "/he") + assertEquals("he", picker.menu.value?.query) + assertEquals(4, picker.menu.value?.matches?.size) + + picker.onText("b", "/zzz") + assertNull(picker.menu.value) + + picker.open("b") + picker.onText("b", "/head ") + assertNull(picker.menu.value) + } + + @Test + fun deletingTheSlashClosesTheMenuAndAnotherParagraphIsIgnored() { + val picker = SlashPicker().apply { setLabels(labels) } + picker.open("b") + + picker.onText("other", "plain") + assertNotNull(picker.menu.value) + picker.onText("b", "") + assertNull(picker.menu.value) + } + + @Test + fun theHighlightWrapsAroundAndResetsWithTheQuery() { + val picker = SlashPicker().apply { setLabels(labels) } + picker.open("b") + picker.commit() + + picker.moveHighlight(-1) + assertEquals(SlashCommand.DIVIDER, picker.menu.value?.highlightedCommand) + picker.moveHighlight(1) + assertEquals(SlashCommand.TEXT, picker.menu.value?.highlightedCommand) + picker.moveHighlight(1) + picker.onText("b", "/h") + assertEquals(0, picker.menu.value?.highlighted) + } + + @Test + fun anyStructuralChangeOrUndoClosesThePicker() { + val block = Block.TextBlock(MutableStateFlow("/")) + editor.load(listOf(block)) + editor.slash.open(block.id) + + editor.addBlock(Block.QuoteBlock()) + + assertNull(editor.slash.menu.value) + } + + // --- picking a command ------------------------------------------------------------------------ + + @Test + fun pickingAHeadingReplacesTheParagraphWithAnEmptyHeadingInOneUndoStep() { + val block = Block.TextBlock(MutableStateFlow("/h2")) + editor.load(listOf(block)) + editor.slash.open(block.id) + + assertTrue(editor.applySlash(block.id, SlashCommand.H2)) + + val heading = editor.blocks.single() as Block.HBlock + assertEquals(BlockType.H2, heading.blockType) + assertEquals("", heading.text.value) + assertNull(editor.slash.menu.value) + editor.undo() + assertEquals("/h2", (editor.blocks.single() as Block.TextBlock).text.value) + editor.redo() + assertEquals(BlockType.H2, (editor.blocks.single() as Block.HBlock).blockType) + } + + @Test + fun everyListKindStartsWithOneEmptyItemInFocus() { + listOf( + SlashCommand.CHECKLIST to BlockType.LIST_CHECK, + SlashCommand.BULLETS to BlockType.LIST_BULLET, + SlashCommand.NUMBERED to BlockType.LIST_NUMBER, + ).forEach { (command, type) -> + val block = Block.TextBlock(MutableStateFlow("/x")) + editor.load(listOf(block)) + + editor.applySlash(block.id, command) + + val list = editor.blocks.single() as Block.ListBlock + assertEquals(type, list.blockType) + assertEquals(listOf(""), list.items.value.map { it.value }) + } + } + + @Test + fun quoteAndLinkReplaceTheParagraph() { + val quote = Block.TextBlock(MutableStateFlow("/quo")) + editor.load(listOf(quote)) + editor.applySlash(quote.id, SlashCommand.QUOTE) + assertTrue(editor.blocks.single() is Block.QuoteBlock) + + val link = Block.TextBlock(MutableStateFlow("/link")) + editor.load(listOf(link)) + editor.applySlash(link.id, SlashCommand.LINK) + assertTrue(editor.blocks.single() is Block.LinkBlock) + } + + @Test + fun pickingTextOnlyClearsTheQuery() { + val block = Block.TextBlock(MutableStateFlow("/te")) + editor.load(listOf(block)) + + editor.applySlash(block.id, SlashCommand.TEXT) + + assertSame(block, editor.blocks.single()) + assertEquals("", block.text.value) + editor.undo() + assertEquals("/te", block.text.value) + } + + @Test + fun aDividerIsFollowedByAParagraphToGoOnIn() { + val block = Block.TextBlock(MutableStateFlow("/div")) + editor.load(listOf(block)) + + editor.applySlash(block.id, SlashCommand.DIVIDER) + + assertEquals(listOf("SeparatorBlock", "TextBlock"), editor.blocks.map { it::class.simpleName }) + assertEquals("", (editor.blocks[1] as Block.TextBlock).text.value) + editor.undo() + assertEquals("/div", (editor.blocks.single() as Block.TextBlock).text.value) + } + + @Test + fun onlyAParagraphCanBeConverted() { + val heading = Block.HBlock(MutableStateFlow("Title"), BlockType.H1) + editor.load(listOf(heading)) + + assertFalse(editor.applySlash(heading.id, SlashCommand.QUOTE)) + assertFalse(editor.applySlash("gone", SlashCommand.QUOTE)) + assertEquals("Title", heading.text.value) + } +} diff --git a/app/src/test/java/com/pasich/encly/presentation/editor/state/SmartPasteTest.kt b/app/src/test/java/com/pasich/encly/presentation/editor/state/SmartPasteTest.kt new file mode 100644 index 00000000..e4b930b3 --- /dev/null +++ b/app/src/test/java/com/pasich/encly/presentation/editor/state/SmartPasteTest.kt @@ -0,0 +1,229 @@ +package com.pasich.encly.presentation.editor.state + +import com.pasich.encly.dynamicBlocks.Block +import com.pasich.encly.dynamicBlocks.BlockType +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Smart Paste: what pasted text becomes, as explicit fixtures. Rules under test: a blank line (any + * number of them) starts the next paragraph and a single line break stays inside its paragraph; + * whitespace-only lines at the ends are not content and are dropped, none in between is; Markdown + * marks become their blocks; whatever the editor has no block for stays readable text. + */ +class SmartPasteTest { + private val editor = BlockEditorState { 0L } + + // --- what text becomes --------------------------------------------------------------------- + + @Test + fun aBlankLineStartsAParagraphAndASingleLineBreakStaysInsideIt() { + assertEquals( + listOf("P:one\ntwo", "P:three"), + shape(pasted("one", "two", "", "three")), + ) + } + + @Test + fun severalBlankLinesAreOneParagraphBreak() { + // An empty paragraph is never stored, so extra blank lines could not survive anyway. + assertEquals(listOf("P:one", "P:two"), shape(pasted("one", "", "", " ", "two"))) + } + + @Test + fun whitespaceOnlyLinesAtEitherEndAreDroppedButNothingElse() { + assertEquals(listOf("P:text"), shape(pasted("", " ", "text", "\t", ""))) + // Leading spaces of a whole paragraph are layout, not content, when it becomes a block. + assertEquals(listOf("P:indented"), shape(pasted(" indented"))) + assertTrue(pasted("", " ", "\t").isEmpty()) + } + + @Test + fun headingsLevelsOneToFourAndDeeperOnesAsFour() { + assertEquals( + listOf("H1:A", "H2:B", "H3:C", "H4:D", "H4:E"), + shape(pasted("# A", "## B", "### C", "#### D", "##### E")), + ) + } + + @Test + fun adjacentListsOfDifferentKindsStaySeparateLists() { + assertEquals( + listOf("LIST_BULLET:a|b", "LIST_NUMBER:c|d", "LIST_BULLET:e"), + shape(pasted("- a", "* b", "1. c", "2) d", "+ e")), + ) + } + + @Test + fun checklistsInEveryMarkTheEditorKnows() { + assertEquals( + listOf("LIST_CHECK:[ ]a|[x]b|[ ]c|[x]d|[ ]e"), + shape(pasted("- [ ] a", "- [x] b", "[] c", "[x] d", "* [ ] e")), + ) + } + + @Test + fun theEditorsOwnBulletGlyphIsABullet() { + assertEquals(listOf("LIST_BULLET:a|b"), shape(pasted("• a", "• b"))) + } + + @Test + fun aListContinuesAcrossABlankLineAndAParagraphEndsIt() { + assertEquals( + listOf("P:intro", "LIST_BULLET:a|b", "P:outro"), + shape(pasted("intro", "", "- a", "", "- b", "outro")), + ) + } + + @Test + fun quotesRulesAndAddresses() { + assertEquals( + listOf("QUOTE:one\ntwo", "---", "---", "---", "LINK:https://example.com/a", "P:see https://example.com"), + shape(pasted("> one", "> two", "---", "***", "___", " https://example.com/a ", "see https://example.com")), + ) + } + + @Test + fun nothingTheEditorHasNoBlockForIsRewrittenOrDropped() { + val kept = listOf( + "use
here
& there", + "[[Note|alias]] and ![[image.png]]", + "| a | b |", + "2024. Report", + "> [!note] Callout title", + ) + assertEquals( + listOf( + "P:use
here
& there\n[[Note|alias]] and ![[image.png]]\n| a | b |", + "P:2024. Report", + "QUOTE:[!note] Callout title", + ), + shape(pasted(kept[0], kept[1], kept[2], "", kept[3], "", kept[4])), + ) + } + + @Test + fun fencedCodeKeepsItsFencesAndItsBlankLines() { + assertEquals( + listOf("P:```kotlin\nval a = 1\n\nval b = 2\n```"), + shape(pasted("```kotlin", "val a = 1", "", "val b = 2", "```")), + ) + } + + @Test + fun trailingSpacesOfALineAreTheOnlyWhitespaceThatIsTrimmedInsideParagraphs() { + assertEquals(listOf("P:a\nb"), shape(pasted("a ", "b "))) + } + + // --- pasting into the editor ---------------------------------------------------------------- + + @Test + fun aMultiParagraphPasteIntoAnEmptyParagraphIsOneUndoStep() { + val block = Block.TextBlock() + editor.load(listOf(block)) + + editor.breakLine(block.id, paste("", 0, "# Trip\n\nPack light\nand early\n\n- milk\n- eggs")) + + assertEquals(listOf("H1:Trip", "P:Pack light\nand early", "LIST_BULLET:milk|eggs"), shape(editor.blocks)) + editor.undo() + assertEquals(listOf("P:"), shape(editor.blocks)) + editor.redo() + assertEquals(3, editor.blocks.size) + } + + @Test + fun aSingleParagraphGoesIntoTheBlockAtTheCursorLineBreaksAndAll() { + val block = Block.TextBlock(MutableStateFlow("Start end")) + editor.load(listOf(block)) + + val kept = editor.breakLine(block.id, paste("Start end", 6, "one\ntwo")) + + assertEquals("Start one\ntwoend", kept) + assertEquals(listOf("P:Start one\ntwoend"), shape(editor.blocks)) + assertFocus(0, caret = "Start one\ntwo".length) + editor.undo() + assertEquals(listOf("P:Start end"), shape(editor.blocks)) + } + + @Test + fun structureInTheMiddleOfTextSplitsItAndTheRestFollows() { + val block = Block.TextBlock(MutableStateFlow("abcd")) + editor.load(listOf(block)) + + val kept = editor.breakLine(block.id, paste("abcd", 2, "x\n- l1\n- l2")) + + assertEquals("abx", kept) + assertEquals(listOf("P:abx", "LIST_BULLET:l1|l2", "P:cd"), shape(editor.blocks)) + assertFocus(2, caret = 0) + editor.undo() + assertEquals(listOf("P:abcd"), shape(editor.blocks)) + } + + @Test + fun aHeadingFirstInAPasteAtTheEndOfTextLeavesTheTextAlone() { + val block = Block.TextBlock(MutableStateFlow("Intro")) + editor.load(listOf(block)) + + editor.breakLine(block.id, paste("Intro", 5, "# Head\ntext")) + + assertEquals(listOf("P:Intro", "H1:Head", "P:text"), shape(editor.blocks)) + } + + @Test + fun pastingBlankLinesOnlyChangesNothing() { + val block = Block.TextBlock(MutableStateFlow("abcd")) + editor.load(listOf(block)) + + val kept = editor.breakLine(block.id, paste("abcd", 2, "\n \n")) + + assertEquals("abcd", kept) + assertEquals(listOf("P:abcd"), shape(editor.blocks)) + } + + @Test + fun aParagraphPastedIntoAHeadingKeepsOneLine() { + val heading = Block.HBlock(MutableStateFlow("Title"), BlockType.H2) + editor.load(listOf(heading)) + + val kept = editor.breakLine(heading.id, paste("Title", 5, " a\nb")) + + assertEquals("Title a b", kept) + assertEquals(listOf("H2:Title a b"), shape(editor.blocks)) + } + + // --- helpers ------------------------------------------------------------------------------- + + private fun pasted(vararg lines: String) = pastedBlocks(lines.toList()) + + private fun paste(text: String, cursor: Int, pasted: String) = + LineBreak(text.take(cursor), pasted.split('\n'), text.drop(cursor)) + + private fun assertFocus(index: Int, caret: Int?) { + var request: FocusRequest? = null + runTest { request = editor.selection.requests.first() } + assertEquals(editor.blocks[index].id, request?.blockId) + assertEquals(caret, request?.caret) + } + + private fun shape(blocks: List): List = blocks.map { block -> + when (block) { + is Block.TextBlock -> "P:${block.text.value}" + + is Block.HBlock -> "${block.blockType}:${block.text.value}" + + is Block.QuoteBlock -> "QUOTE:${block.text.value}" + + is Block.LinkBlock -> "LINK:${block.block.value.url}" + + is Block.SeparatorBlock -> "---" + + is Block.ListBlock -> block.blockType.name + ":" + block.items.value.joinToString("|") { + (if (block.blockType == BlockType.LIST_CHECK) if (it.isCheck) "[x]" else "[ ]" else "") + it.value + } + } + } +} diff --git a/app/src/test/java/com/pasich/encly/presentation/viewmodel/EditNoteViewModelComfortTest.kt b/app/src/test/java/com/pasich/encly/presentation/viewmodel/EditNoteViewModelComfortTest.kt new file mode 100644 index 00000000..68619acf --- /dev/null +++ b/app/src/test/java/com/pasich/encly/presentation/viewmodel/EditNoteViewModelComfortTest.kt @@ -0,0 +1,295 @@ +package com.pasich.encly.presentation.viewmodel + +import androidx.compose.runtime.snapshots.Snapshot +import androidx.lifecycle.SavedStateHandle +import com.pasich.encly.core.security.VaultLockEvents +import com.pasich.encly.core.serialization.BlockConverter +import com.pasich.encly.data.model.Note +import com.pasich.encly.data.model.NoteWithTag +import com.pasich.encly.domain.usecase.note.UpdateNoteTrashStatusUseCase +import com.pasich.encly.dynamicBlocks.Block +import com.pasich.encly.dynamicBlocks.BlockType +import com.pasich.encly.presentation.editor.state.FindState +import com.pasich.encly.presentation.editor.state.LineBreak +import com.pasich.encly.presentation.editor.state.NoteTemplate +import com.pasich.encly.presentation.editor.state.SlashCommand +import com.pasich.encly.presentation.editor.state.blocks +import com.pasich.encly.testutil.TestNotesRepository +import com.pasich.encly.testutil.TestSettingsRepository +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +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 Writing Comfort features around the editor ViewModel: guards, the lock and what is saved. */ +@OptIn(ExperimentalCoroutinesApi::class) +class EditNoteViewModelComfortTest { + private val dispatcher = StandardTestDispatcher() + private val settings = TestSettingsRepository() + private val locked = MutableStateFlow(false) + private val lockEvents = object : VaultLockEvents { + override val locked = this@EditNoteViewModelComfortTest.locked + } + private val note = Note( + id = NOTE_ID, + title = "Cats", + value = BlockConverter.blocksToJson(listOf(Block.TextBlock(MutableStateFlow("the cat sat")))), + ) + private val repository = TestNotesRepository().apply { + allNotesWithTags.value = listOf(NoteWithTag(note = note, tag = null)) + insertResult = INSERTED_ID + } + + @Before + fun setUp() { + Dispatchers.setMain(dispatcher) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + private fun viewModel(noteId: Long = NOTE_ID) = EditNoteViewModel( + notesRepository = repository, + savedStateHandle = SavedStateHandle(mapOf("idNote" to noteId)), + updateNoteTrashStatusUseCase = UpdateNoteTrashStatusUseCase(repository), + settingsRepository = settings, + appScope = CoroutineScope(dispatcher), + ioDispatcher = dispatcher, + copyTitle = { "$it (Copy)" }, + lockEvents = lockEvents, + ) + + private fun TestScope.collecting(viewModel: EditNoteViewModel) { + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { viewModel.simpleEdit.collect {} } + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { viewModel.templatesOffered.collect {} } + } + + private val enter = LineBreak("", listOf("", ""), "") + + // --- find --------------------------------------------------------------------------------------- + + @Test + fun openingTheNoteAndUsingFindOrSlashSavesNothing() = runTest(dispatcher) { + val viewModel = viewModel() + advanceUntilIdle() + + viewModel.openFind() + viewModel.onFindQueryChanged("cat") + viewModel.findNext() + viewModel.slashPicker.open(viewModel.blocks.first().id) + viewModel.slashPicker.dismiss() + viewModel.closeFind() + advanceTimeBy(10_000) + advanceUntilIdle() + + assertEquals(0, repository.updateCalls) + assertEquals(0, repository.insertCalls) + } + + @Test + fun findFollowsTheTextWhileItIsOpen() = runTest(dispatcher) { + val viewModel = viewModel() + advanceUntilIdle() + viewModel.openFind() + viewModel.onFindQueryChanged("cat") + assertEquals(1, viewModel.find.value.matches.size) + + viewModel.onBlockTextChanged(viewModel.blocks.first(), "cat cat") + advanceUntilIdle() + + assertEquals(2, viewModel.find.value.matches.size) + } + + @Test + fun findWorksInALockedNoteBecauseItOnlyReads() = runTest(dispatcher) { + val viewModel = viewModel() + advanceUntilIdle() + viewModel.toggleLockEditor() + + viewModel.openFind() + viewModel.onFindQueryChanged("sat") + + assertEquals(1, viewModel.find.value.matches.size) + assertEquals("the cat sat", (viewModel.blocks.single() as Block.TextBlock).text.value) + } + + @Test + fun theQueryAndMatchesAreGoneWhenTheVaultLocks() = runTest(dispatcher) { + val viewModel = viewModel() + advanceUntilIdle() + viewModel.openFind() + viewModel.onFindQueryChanged("cat") + viewModel.slashPicker.open(viewModel.blocks.first().id) + + locked.value = true + advanceUntilIdle() + + assertEquals(FindState(), viewModel.find.value) + assertNull(viewModel.slashPicker.menu.value) + } + + // --- slash ---------------------------------------------------------------------------------------- + + @Test + fun slashOpensInAnEmptyParagraphAndEnterPicksTheHighlightedCommand() = runTest(dispatcher) { + val viewModel = viewModel(noteId = -1L) + collecting(viewModel) + advanceUntilIdle() + val block = viewModel.blocks.single() + + viewModel.onSlashTyped(block, "/", started = true) + viewModel.slashPicker.commit() + viewModel.slashPicker.moveHighlight(1) // Heading 1 + + assertEquals("", viewModel.onLineBreak(block, enter)) + + assertEquals(BlockType.H1, (viewModel.blocks.single() as Block.HBlock).blockType) + assertNull(viewModel.slashPicker.menu.value) + viewModel.undo() + assertTrue(viewModel.blocks.single() is Block.TextBlock) + } + + @Test + fun enterStillSplitsTheParagraphWhileTheMenuIsNotShown() = runTest(dispatcher) { + val viewModel = viewModel(noteId = -1L) + collecting(viewModel) + advanceUntilIdle() + val block = viewModel.blocks.single() + + viewModel.onSlashTyped(block, "/", started = true) // not committed: hidden + + viewModel.onLineBreak(block, enter) + + assertEquals(2, viewModel.blocks.size) + assertNull(viewModel.slashPicker.menu.value) + } + + @Test + fun slashNeverOpensInSimpleEdit() = runTest(dispatcher) { + settings.simpleEditFlow.value = true + val viewModel = viewModel(noteId = -1L) + collecting(viewModel) + advanceUntilIdle() + + viewModel.onSlashTyped(viewModel.blocks.single(), "/", started = true) + + assertNull(viewModel.slashPicker.menu.value) + } + + @Test + fun slashDoesNotOpenInAReadOnlyNoteOrOnAnotherKindOfBlock() = runTest(dispatcher) { + val viewModel = viewModel() + collecting(viewModel) + advanceUntilIdle() + viewModel.toggleLockEditor() + + viewModel.onSlashTyped(viewModel.blocks.single(), "/", started = true) + assertNull(viewModel.slashPicker.menu.value) + + viewModel.applySlash(viewModel.blocks.single(), SlashCommand.QUOTE) + assertTrue(viewModel.blocks.single() is Block.TextBlock) + } + + // --- paste in Simple Edit -------------------------------------------------------------------------- + + @Test + fun simpleEditKeepsAPastedTextAsPlainTextInTheBlock() = runTest(dispatcher) { + settings.simpleEditFlow.value = true + val viewModel = viewModel(noteId = -1L) + collecting(viewModel) + advanceUntilIdle() + val block = viewModel.blocks.single() + + val kept = viewModel.onLineBreak(block, LineBreak("", listOf("# Title", "- item", "", "text"), "")) + + assertNull(kept) // the field keeps the text, markers and blank line included + assertEquals(1, viewModel.blocks.size) + } + + // --- templates ----------------------------------------------------------------------------------- + + @Test + fun templatesAreOfferedOnlyForABrandNewEmptyNote() = runTest(dispatcher) { + val fresh = viewModel(noteId = -1L) + collecting(fresh) + advanceUntilIdle() + assertTrue(fresh.templatesOffered.value) + + fresh.onBlockTextChanged(fresh.blocks.single(), "a") + advanceUntilIdle() + assertFalse(fresh.templatesOffered.value) + + fresh.undo() + advanceUntilIdle() + assertTrue(fresh.templatesOffered.value) + + val opened = viewModel(noteId = NOTE_ID) + collecting(opened) + advanceUntilIdle() + assertFalse(opened.templatesOffered.value) + } + + @Test + fun templatesAreNotOfferedInSimpleEdit() = runTest(dispatcher) { + settings.simpleEditFlow.value = true + val viewModel = viewModel(noteId = -1L) + collecting(viewModel) + advanceUntilIdle() + + assertFalse(viewModel.templatesOffered.value) + } + + @Test + fun pickingATemplateFillsTheNoteAndHidesTheOffer() = runTest(dispatcher) { + val viewModel = viewModel(noteId = -1L) + collecting(viewModel) + advanceUntilIdle() + + viewModel.applyTemplate(NoteTemplate.CHECKLIST.blocks { "To do" }) + Snapshot.sendApplyNotifications() // what Compose does for snapshotFlow in the app + advanceUntilIdle() + + assertEquals(2, viewModel.blocks.size) + assertEquals("To do", (viewModel.blocks[0] as Block.HBlock).text.value) + assertFalse(viewModel.templatesOffered.value) + viewModel.undo() + Snapshot.sendApplyNotifications() + advanceUntilIdle() + assertTrue(viewModel.templatesOffered.value) + } + + @Test + fun duplicateAndDeleteOfABlockAreOneUndoStepEach() = runTest(dispatcher) { + val viewModel = viewModel() + advanceUntilIdle() + + viewModel.duplicateBlock(viewModel.blocks.single()) + assertEquals(2, viewModel.blocks.size) + viewModel.undo() + assertEquals(1, viewModel.blocks.size) + assertFalse(viewModel.canUndo.value) + } + + private companion object { + const val NOTE_ID = 12L + const val INSERTED_ID = 40L + } +} diff --git a/app/src/test/java/com/pasich/encly/ui/screens/EditorWritingTest.kt b/app/src/test/java/com/pasich/encly/ui/screens/EditorWritingTest.kt index d9fb60e9..cb192a9f 100644 --- a/app/src/test/java/com/pasich/encly/ui/screens/EditorWritingTest.kt +++ b/app/src/test/java/com/pasich/encly/ui/screens/EditorWritingTest.kt @@ -13,6 +13,7 @@ import androidx.compose.ui.test.performKeyInput import androidx.compose.ui.test.performTextInput import androidx.compose.ui.test.performTextInputSelection import androidx.compose.ui.test.pressKey +import androidx.compose.ui.test.withKeyDown import androidx.compose.ui.text.TextRange import com.pasich.encly.R import com.pasich.encly.core.serialization.BlockConverter @@ -106,6 +107,65 @@ class EditorWritingTest : ComposeScreenTest() { assertEquals(listOf("Hello world"), texts()) } + @Test + fun aSlashInAnEmptyParagraphOpensThePickerAndPickingAHeadingConvertsIt() { + open(text("")) + val field = rule.onAllNodes(hasSetTextAction())[1] + field.performClick() + + field.performTextInput("/") + + waitFor { editor.slashPicker.menu.value?.isShown == true } + rule.onNodeWithText(str(R.string.block_heading_level, 2)).performClick() + waitFor { editor.blocks.first() is Block.HBlock } + assertEquals(BlockType.H2, (editor.blocks.first() as Block.HBlock).blockType) + rule.runOnUiThread { editor.undo() } + waitFor { editor.blocks.first() is Block.TextBlock } + assertEquals(listOf("/"), texts()) + } + + @Test + fun aSlashTypedInTheMiddleOfTextNeverOpensThePicker() { + open(text("a b")) + paragraph("a b").performClick() + paragraph("a b").performTextInputSelection(TextRange(2)) + + paragraph("a b").performTextInput("/") + + waitFor { texts() == listOf("a /b") } + assertEquals(null, editor.slashPicker.menu.value) + } + + @Test + fun findShowsTheCountStepsThroughMatchesAndLeavesTheNoteAlone() { + open(text("the cat sat"), text("another cat")) + rule.runOnUiThread { editor.openFind() } + val query = rule.onNode(hasSetTextAction() and hasContentDescription(str(R.string.search_notes))) + + query.performTextInput("cat") + + rule.onNodeWithText(str(R.string.find_count, 1, 2)).assertExists() + rule.onNode(hasContentDescription(str(R.string.find_next)) and hasClickAction()).performClick() + rule.onNodeWithText(str(R.string.find_count, 2, 2)).assertExists() + assertEquals(listOf("the cat sat", "another cat"), texts()) + rule.onNode(hasContentDescription(str(R.string.find_close)) and hasClickAction()).performClick() + waitFor { !editor.find.value.isOpen } + assertEquals("", editor.find.value.query) + } + + @Test + fun controlFOpensFindAndEscapeClosesIt() { + open(text("body")) + paragraph("body").performClick() + + paragraph("body").performKeyInput { withKeyDown(Key.CtrlLeft) { pressKey(Key.F) } } + waitFor { editor.find.value.isOpen } + + rule.onNode(hasSetTextAction() and hasContentDescription(str(R.string.search_notes))) + .performKeyInput { pressKey(Key.Escape) } + waitFor { !editor.find.value.isOpen } + } + @Test fun theKeyboardToggleFocusesTheWorkingBlock() { open(text("body")) diff --git a/docs/architecture.md b/docs/architecture.md index cfe9705d..63690879 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -52,6 +52,16 @@ Note editor writes are serialized by its ViewModel and must complete before navi success. Notes, tags and tasks remain local Room records inside the encrypted database. Room Flows drive list state; the active note list is derived from selected tag and sort state. +The block editor keeps its logic out of the composables: `BlockEditorState` owns the blocks, the +undo history and the focus; the slash picker (`SlashPicker`), Find in this note (`NoteFind`), +Smart Paste, block actions and templates are plain state or extension functions beside it, tested +on the JVM. Find, the slash picker and the template offer are transient UI state in the editor +ViewModel: never written to the note, to `SavedStateHandle` or to disk, and cleared when the vault +locks. They use the existing block kinds only, so the stored `Note.value` format is unchanged and +a note is saved only when the user edits it. Smart Paste reads pasted plain text through the +importer's `MarkdownBlockMapper` in its paste mode (nothing rewritten); the reading density +(`NoteLineSpacing`) is presentation only. + ## Navigation Startup resolves vault state before rendering the main graph. Locked sessions route to the lock From c2a8abe05d144a4808165fe2b3c6afeebca1d799 Mon Sep 17 00:00:00 2001 From: pasichdev Date: Thu, 15 Oct 2026 13:11:02 +0300 Subject: [PATCH 10/18] fix(security): bound PIN guesses around the duress PIN and hide the decoy - Keep a second miss counter (pin.strikes) that only the real PIN, a new PIN slot and the erase clear: typing the duress PIN between guesses ends a running lockout but no longer resets the guess budget. From the 10th miss since the PIN was last right every miss is locked out. - Charge a miss when Settings refuses a PIN for being another PIN, and refuse setting the wipe or duress PIN during a lockout. - Record a turned-off duress PIN inside decoy.key (another AAD) instead of the pin.decoy.notice entry, so the store never shows a decoy vault and Settings reports the duress PIN as off after any new PIN salt. - Show the same Settings in the decoy vault; every real-vault action fails like a storage error. Backup decrypt and import are refused by BackupManager, and wipeAndReset reports whether it erased. - Write the SQLite header magic as an escape instead of a raw NUL byte. Refs: #61 --- .../java/com/pasich/encly/MainActivity.kt | 8 +- .../core/security/AuthenticationManager.kt | 181 +++++++++++++----- .../pasich/encly/core/security/DuressSlots.kt | 3 + .../encly/core/security/SecurityManager.kt | 33 ++-- .../core/security/cipher/SQLCipherUtils.kt | Bin 1298 -> 1320 bytes .../pasich/encly/data/backup/BackupManager.kt | 22 ++- .../components/settings/SettingsBuilder.kt | 7 +- .../presentation/navigation/DecoySession.kt | 36 ---- .../encly/presentation/navigation/NavHost.kt | 8 +- .../screen/settings/DuressPinScreen.kt | 12 +- .../screen/settings/SecuritySettingsScreen.kt | 22 +-- .../screen/settings/SettingsScreen.kt | 3 - .../presentation/viewmodel/BackupViewModel.kt | 9 +- .../viewmodel/SecuritySettingsViewModel.kt | 3 - app/src/main/res/values-de/strings.xml | 1 + app/src/main/res/values-es/strings.xml | 1 + app/src/main/res/values-fr/strings.xml | 1 + app/src/main/res/values-it/strings.xml | 1 + app/src/main/res/values-nl/strings.xml | 1 + app/src/main/res/values-pl/strings.xml | 1 + app/src/main/res/values-pt/strings.xml | 1 + app/src/main/res/values-uk/strings.xml | 1 + app/src/main/res/values/strings.xml | 1 + .../encly/core/security/DuressPinTest.kt | 158 ++++++++++++++- .../core/security/SecurityManagerTest.kt | 2 +- .../encly/data/backup/BackupManagerTest.kt | 17 ++ .../viewmodel/BackupViewModelTest.kt | 15 ++ .../encly/ui/screens/BackupScreenTest.kt | 27 +++ .../encly/ui/screens/SecurityScreensTest.kt | 144 +++++++++++--- .../encly/ui/screens/SettingsScreensTest.kt | 23 ++- .../com/pasich/encly/ui/screens/TestApp.kt | 24 ++- docs/architecture.md | 2 +- 32 files changed, 561 insertions(+), 207 deletions(-) delete mode 100644 app/src/main/java/com/pasich/encly/presentation/navigation/DecoySession.kt diff --git a/app/src/main/java/com/pasich/encly/MainActivity.kt b/app/src/main/java/com/pasich/encly/MainActivity.kt index 0c7a7696..e461e0dc 100644 --- a/app/src/main/java/com/pasich/encly/MainActivity.kt +++ b/app/src/main/java/com/pasich/encly/MainActivity.kt @@ -49,7 +49,6 @@ import com.pasich.encly.presentation.effects.LocalUnlockReveal import com.pasich.encly.presentation.effects.UnlockRevealOverlay import com.pasich.encly.presentation.effects.UnlockRevealState import com.pasich.encly.presentation.navigation.AppNavHost -import com.pasich.encly.presentation.navigation.LocalDecoySession import com.pasich.encly.presentation.navigation.LocalEraseEpoch import com.pasich.encly.presentation.navigation.NavRoutes import com.pasich.encly.presentation.navigation.RelockReturn @@ -145,7 +144,6 @@ class MainActivity : AppCompatActivity() { navController = navController, startDestination = destination.name, eraseEpoch = securityManager::eraseEpoch, - isDecoySession = securityManager::isDecoySession, shielded = shielded, locked = locked, strictKeyboard = strictKeyboard, @@ -311,7 +309,6 @@ fun App( navController: NavHostController, startDestination: String, eraseEpoch: () -> Long, - isDecoySession: () -> Boolean, modifier: Modifier = Modifier, shielded: Boolean = false, locked: Boolean = false, @@ -348,10 +345,7 @@ fun App( .fillMaxSize() .windowInsetsPadding(WindowInsets.statusBars), ) { - CompositionLocalProvider( - LocalEraseEpoch provides eraseEpoch, - LocalDecoySession provides isDecoySession, - ) { + CompositionLocalProvider(LocalEraseEpoch provides eraseEpoch) { AppNavHost( navController = navController, startDestination = startDestination, 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 f940b3eb..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,6 +1,7 @@ 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 @@ -197,7 +198,9 @@ class AuthenticationManager @Inject constructor( } 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 PinChange.IN_USE + 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 @@ -225,10 +228,11 @@ class AuthenticationManager @Inject constructor( * * 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 clears like the - * PIN. 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. + * 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 @@ -267,9 +271,9 @@ class AuthenticationManager @Inject constructor( 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 @@ -278,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 { - checkSlots(pin, parsed, keys, purpose) + checkSlots(pin, parsed, keys, Check(purpose, previous.strikes)) } finally { keys.wipe() } @@ -298,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) @@ -320,10 +325,12 @@ 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 WipePinChange.IN_USE + if (opensDecoySlot(keys.decoyKek, parsed.salt)) { + return if (chargeMiss()) WipePinChange.IN_USE else WipePinChange.FAILED + } writeWipeSlot(keys.wipeKek, parsed.salt) } finally { keys.wipe() @@ -352,12 +359,15 @@ class AuthenticationManager @Inject constructor( * 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). + * 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) return DuressPinChange.FAILED + 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) @@ -374,7 +384,7 @@ class AuthenticationManager @Inject constructor( val dek = openSlotOrNull(parsed, keys.kek) if (dek != null) { SensitiveDataCleaner.clear(dek) - return DuressPinChange.SAME_AS_PIN + return if (chargeMiss()) DuressPinChange.SAME_AS_PIN else DuressPinChange.FAILED } val wipeSlot = store.getBytes(WIPE_SLOT_KEY) val isWipePin = wipeSlot != null && @@ -383,7 +393,7 @@ class AuthenticationManager @Inject constructor( } finally { SensitiveDataCleaner.clear(wipeSlot) } - if (isWipePin) return DuressPinChange.IN_USE + if (isWipePin) return if (chargeMiss()) DuressPinChange.IN_USE else DuressPinChange.FAILED writeDuressSlot(keys.decoyKek, parsed.salt, primaryDek, onNewVault) } finally { keys.wipe() @@ -404,7 +414,6 @@ class AuthenticationManager @Inject constructor( store.edit { putBytes(DECOY_SLOT_KEY, slot) putBytes(DECOY_KEY_KEY, key) - remove(DECOY_NOTICE_KEY) } } finally { SensitiveDataCleaner.clear(slot) @@ -416,17 +425,22 @@ class AuthenticationManager @Inject constructor( * 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 { - val decoyDek = openDecoyKeyOrNull(primaryDek) ?: return false - SensitiveDataCleaner.clear(decoyDek) - return true - } + fun hasDecoyVault(primaryDek: ByteArray): Boolean = decoyKeyState(primaryDek) != null /** - * True after the PIN key had to be reset (see [configurePin]) while a decoy vault existed: - * its duress PIN stopped working. Setting it again opens the same decoy notes. + * 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(): Boolean = store.getBoolean(DECOY_NOTICE_KEY, false) + 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 @@ -564,7 +578,7 @@ class AuthenticationManager @Inject constructor( * 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, purpose: Purpose): PinUnlock { + private fun checkSlots(pin: CharArray, parsed: ParsedSlot, keys: PinKeys, check: Check): PinUnlock { val dek = openSlotOrNull(parsed, keys.kek) // Both tried even when the PIN slot already opened: no early exit. val wipeSlot = store.getBytes(WIPE_SLOT_KEY) @@ -575,15 +589,28 @@ class AuthenticationManager @Inject constructor( SensitiveDataCleaner.clear(wipeSlot) } val decoyDek = openDecoySlotOrNull(keys.decoyKek, parsed.salt) - val matched = match(dek, decoyDek, wipeMatch, purpose) + 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, is PinUnlock.Decoy -> { - store.edit { removePrefix(LOCKOUT_PREFIX) } + is PinUnlock.Success -> { + store.edit { + removePrefix(LOCKOUT_PREFIX) + remove(STRIKES_KEY) + } + matched + } + + // 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 } @@ -639,12 +666,12 @@ class AuthenticationManager @Inject constructor( putBytes(WIPE_SLOT_KEY, decoy) putBytes(DECOY_SLOT_KEY, duressSlot) putBytes(DECOY_KEY_KEY, decoyKey) - remove(DECOY_NOTICE_KEY) 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 { @@ -694,10 +721,10 @@ class AuthenticationManager @Inject constructor( val slot = sealSlot(dek, kek, salt) val decoy = if (change == WipeSlotChange.KEEP) null else decoyWipeSlot() val duressSlot = if (decoyChange == WipeSlotChange.KEEP) null else randomSlot() - // Told only when there was a decoy vault to lose; the store can not know it without a DEK. - val tellDuress = decoyChange == WipeSlotChange.TURN_OFF_AND_TELL && hasDecoyVault(dek) - // A first PIN (no salt yet) starts a vault without any decoy key either. - val decoyKey = if (store.contains(DECOY_KEY_KEY)) 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) @@ -707,8 +734,8 @@ class AuthenticationManager @Inject constructor( 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) - if (tellDuress) putBoolean(DECOY_NOTICE_KEY, true) removePrefix(LOCKOUT_PREFIX) + remove(STRIKES_KEY) } } finally { SensitiveDataCleaner.clear(slot) @@ -729,7 +756,7 @@ class AuthenticationManager @Inject constructor( primaryDek: ByteArray, onNewVault: () -> Boolean, ): DuressPinChange { - val kept = openDecoyKeyOrNull(primaryDek) + 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) } @@ -740,7 +767,6 @@ class AuthenticationManager @Inject constructor( store.edit { putBytes(DECOY_SLOT_KEY, duressSlot) putBytes(DECOY_KEY_KEY, decoyKey) - remove(DECOY_NOTICE_KEY) } } finally { SensitiveDataCleaner.clear(duressSlot) @@ -794,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()) @@ -806,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) } } @@ -886,26 +922,46 @@ class AuthenticationManager @Inject constructor( } /** The decoy DEK sealed under [primaryDek] in `decoy.key`, or null when there is none. */ - private fun openDecoyKeyOrNull(primaryDek: ByteArray): ByteArray? { + private fun openDecoyKeyOrNull(primaryDek: ByteArray): DecoyKey? { val sealed = store.getBytes(DECOY_KEY_KEY) ?: return null val key = decoyKeyKek(primaryDek) return try { - openDecoy(sealed, key, DECOY_KEY_AAD) + // 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(sealed) SensitiveDataCleaner.clear(key) } } - private fun sealDecoyKey(decoyDek: ByteArray, primaryDek: ByteArray): ByteArray { + private fun sealDecoyKey(decoyDek: ByteArray, primaryDek: ByteArray, active: Boolean = true): ByteArray { val key = decoyKeyKek(primaryDek) return try { - sealDecoy(decoyDek, key, DECOY_KEY_AAD) + sealDecoy(decoyDek, key, if (active) DECOY_KEY_AAD else DECOY_KEY_OFF_AAD) } finally { SensitiveDataCleaner.clear(key) } } + /** `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, @@ -934,6 +990,18 @@ class AuthenticationManager @Inject constructor( 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 } @@ -948,7 +1016,6 @@ class AuthenticationManager @Inject constructor( 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_NOTICE_KEY = "pin.decoy.notice" private const val DECOY_PREFIX = "decoy." private const val DECOY_KEY_KEY = "decoy.key" private const val AUTH_PREFIX = "auth." @@ -960,9 +1027,19 @@ 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 PBKDF2_ITERATIONS = 600_000 @@ -981,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/DuressSlots.kt b/app/src/main/java/com/pasich/encly/core/security/DuressSlots.kt index d96f83e3..c394f28b 100644 --- a/app/src/main/java/com/pasich/encly/core/security/DuressSlots.kt +++ b/app/src/main/java/com/pasich/encly/core/security/DuressSlots.kt @@ -21,6 +21,9 @@ 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) 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 65cf128d..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 @@ -176,7 +176,7 @@ class SecurityManager @Inject constructor( /** * True while the open session is the decoy vault the duress PIN opened. Nothing on screen - * may say so; Settings uses it to leave out what belongs to the real vault. + * 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 @@ -516,11 +516,19 @@ class SecurityManager @Inject constructor( return authenticationManager.removeDuressPin() && secureDatabaseManager.wipeIfClosed(VaultFile.DECOY) } - /** Whether the real vault has a decoy vault; false outside a session of the real vault. */ - fun hasDuressPin(): Boolean { + /** + * 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 { - authenticationManager.hasDecoyVault(dek) + check(dek) } finally { SensitiveDataCleaner.clear(dek) } @@ -642,9 +650,12 @@ class SecurityManager @Inject constructor( sessionUnlockedWithRecovery = false } - /** Erases everything: both vaults, every key slot. Never from a decoy session. */ - fun wipeAndReset() { - if (isDecoySession()) return + /** + * 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() @@ -654,6 +665,7 @@ class SecurityManager @Inject constructor( vaultStore.clear() appFlags.edit().clear().commit() securityStatus = InitialStatus.ONBOARDING + return true } fun getSettingsAuth(): AuthSettings = AuthSettings( @@ -662,9 +674,8 @@ class SecurityManager @Inject constructor( isUserCreatedSeedKey = seedPhraseManager.hasRecoverySeed(), hasPinSlot = authenticationManager.hasPinSlot(), wipePinTurnedOff = authenticationManager.wipePinTurnedOff(), - isDecoySession = isDecoySession(), hasDuressPin = hasDuressPin(), - duressPinTurnedOff = !isDecoySession() && authenticationManager.duressPinTurnedOff(), + duressPinTurnedOff = duressPinTurnedOff(), ) } @@ -678,10 +689,8 @@ data class AuthSettings( val isUserCreatedSeedKey: Boolean, val hasPinSlot: Boolean = authType == AuthType.PIN, val wipePinTurnedOff: Boolean = false, - /** The decoy vault is open: Settings leaves out everything of the real vault. */ - val isDecoySession: Boolean = false, /** The real vault has a duress PIN (known only in its own session). */ val hasDuressPin: Boolean = false, - /** The PIN key was reset, so the duress PIN stopped working; setting it again keeps the notes. */ + /** 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/cipher/SQLCipherUtils.kt b/app/src/main/java/com/pasich/encly/core/security/cipher/SQLCipherUtils.kt index 8d86b912ab2946a69c14a162ea1b81a38bf64054..af4b7a3942b6b2ffb1f18668f8f0145898e81a6b 100644 GIT binary patch delta 43 xcmbQlwSsGd1*33`fs%qoYF 0L } 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 e1627fa6..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 @@ -31,14 +31,12 @@ class SettingsBuilder { showTasks: Boolean, simpleEdit: Boolean, navigation: SettingsNavigation, - backupAvailable: Boolean = true, - ): List = listOfNotNull( + ): List = listOf( general(viewModel, navigation), notesAndTasks(viewModel, showTasks, simpleEdit), single(R.string.settings_privacy, R.string.security_title, R.string.security_subtitle, EnclyIcons.Shield) { navigation.onSecurity() }, - // Backups belong to the real vault; the decoy one lists none. single( R.string.settings_backup, R.string.backup_title, @@ -46,8 +44,7 @@ class SettingsBuilder { EnclyIcons.Download, ) { navigation.onBackup() - }.takeIf { backupAvailable }, - // Import goes into whichever vault is open, so the decoy vault has it too. + }, single( R.string.settings_import, R.string.import_title, diff --git a/app/src/main/java/com/pasich/encly/presentation/navigation/DecoySession.kt b/app/src/main/java/com/pasich/encly/presentation/navigation/DecoySession.kt deleted file mode 100644 index d9dc978e..00000000 --- a/app/src/main/java/com/pasich/encly/presentation/navigation/DecoySession.kt +++ /dev/null @@ -1,36 +0,0 @@ -package com.pasich.encly.presentation.navigation - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.staticCompositionLocalOf -import androidx.navigation.NavHostController - -/** - * SecurityManager.isDecoySession, which MainActivity provides. It is a function, not a value: - * the vault can differ from one unlock to the next, so it is asked each time a screen composes - * and never kept. Without it every session is the real vault. - */ -val LocalDecoySession = staticCompositionLocalOf<() -> Boolean> { { false } } - -/** Whether the vault that is open now is the decoy one; screens ask this while composing. */ -@Composable -fun isDecoySession(): Boolean = LocalDecoySession.current() - -/** - * Screens that only make sense in the real vault (backup, PIN settings). In a decoy session they - * never compose: the user is sent to Settings instead, where those entries are not listed, so - * nothing on the way hints that another vault exists. - */ -@Composable -internal fun RealVaultOnly(navController: NavHostController, content: @Composable () -> Unit) { - if (isDecoySession()) { - LaunchedEffect(Unit) { - navController.navigate(NavRoutes.SettingsRoute.name) { - popUpTo(NavRoutes.SettingsRoute.name) { inclusive = true } - launchSingleTop = true - } - } - } else { - content() - } -} 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 8ca5ec9c..8cccb1bb 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 @@ -107,7 +107,7 @@ fun AppNavHost(navController: NavHostController, startDestination: String = NavR } animationScreens(NavRoutes.BackupRoute.name) { - RealVaultOnly(navController) { BackupScreen(navController) } + BackupScreen(navController) } animationScreens(NavRoutes.ImportNotesRoute.name) { @@ -131,15 +131,15 @@ fun AppNavHost(navController: NavHostController, startDestination: String = NavR LossRoute(navController, NavRoutes.LegacyVaultRoute, LossReason.OLDER_VERSION) } animationScreens(NavRoutes.PinCodeConfig.name) { - RealVaultOnly(navController) { PinCodeConfigScreen(navController) } + PinCodeConfigScreen(navController) } animationScreens(NavRoutes.WipePinRoute.name) { - RealVaultOnly(navController) { WipePinScreen(navController) } + WipePinScreen(navController) } animationScreens(NavRoutes.DuressPinRoute.name) { - RealVaultOnly(navController) { DuressPinScreen(navController) } + DuressPinScreen(navController) } animationScreens(NavRoutes.LockRoute.name) { 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 index e367d94e..c5151d5b 100644 --- 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 @@ -41,9 +41,9 @@ private val DuressPinTexts = PinFlowTexts( ) /** - * Settings → Security → Duress PIN, only in a session of the real vault (see the guard in - * AppNavHost). 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. + * 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( @@ -93,7 +93,7 @@ private fun PinFlowState.onDuressPinResult(result: DuressPinChange) { } } -/** What the options page shows besides the actions. */ +/** 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) @@ -133,7 +133,9 @@ private fun DuressPinOptions(info: DuressPinInfo, actions: DuressPinActions, mod color = MaterialTheme.colorScheme.onSurfaceVariant, ) Text( - text = stringResource(if (info.isSet) R.string.duress_pin_status_on else R.string.duress_pin_status_off), + 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, ) 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 04c44ae2..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 @@ -139,30 +139,12 @@ private fun SecurityContent( .verticalScroll(rememberScrollState()) .padding(horizontal = EnclyTheme.spacing.gutter, vertical = EnclyTheme.spacing.s), ) { - if (securityState.isDecoySession) { - // Nothing of the real vault: no PIN, phrase, biometric or erase entries, and no notices. - DecoySecurityContent(options, actions) - } else { - RealSecurityContent(securityState, options, vaultBusy, actions) - } - } -} - -/** What Security shows in the decoy vault: only the two preferences that belong to the app. */ -@Composable -private fun DecoySecurityContent(options: SecurityOptions, actions: SecurityActions) { - SectionOverline( - text = stringResource(R.string.auth_title), - modifier = Modifier.padding(top = EnclyTheme.spacing.s), - ) - EnclyGroup { - AutoLockRow(current = options.autoLock, onSelect = actions.onAutoLock) + SecurityRows(securityState, options, vaultBusy, actions) } - PrivacySection(strictKeyboard = options.strictKeyboard, onStrictKeyboard = actions.onStrictKeyboard) } @Composable -private fun RealSecurityContent( +private fun SecurityRows( securityState: SecuritySettingsViewModel.SecuritySettingsUiState, options: SecurityOptions, vaultBusy: Boolean, 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 709d8e22..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 @@ -23,7 +23,6 @@ import com.pasich.encly.presentation.components.settings.SettingsNavigation 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.navigation.isDecoySession import com.pasich.encly.presentation.viewmodel.SettingsViewModel import com.pasich.encly.presentation.viewmodel.enclyViewModel import com.pasich.encly.ui.theme.EnclyTheme @@ -40,7 +39,6 @@ fun SettingsScreen( val languageDialogVisible by settingViewState.languageDialogVisible.collectAsState() - val decoy = isDecoySession() val settingsBuilder = remember { SettingsBuilder() } val settingsCategories = settingsBuilder.buildSettingsCategories( viewModel = settingViewState, @@ -54,7 +52,6 @@ fun SettingsScreen( onAbout = { navController?.navigate(NavRoutes.AboutRoute.name) }, onFaq = { navController?.navigate(NavRoutes.FaqRoute.name) }, ), - backupAvailable = !decoy, ) Scaffold( 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/SecuritySettingsViewModel.kt b/app/src/main/java/com/pasich/encly/presentation/viewmodel/SecuritySettingsViewModel.kt index b3dc7454..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 @@ -66,7 +66,6 @@ class SecuritySettingsViewModel @Inject constructor( biometricStatus = securityManager.biometricStatus(), hasPinSlot = authSettings.hasPinSlot, wipePinTurnedOff = authSettings.wipePinTurnedOff, - isDecoySession = authSettings.isDecoySession, hasDuressPin = authSettings.hasDuressPin, duressPinTurnedOff = authSettings.duressPinTurnedOff, loaded = true, @@ -216,8 +215,6 @@ 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 decoy vault is open: the page shows nothing of the real vault. */ - val isDecoySession: 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. */ diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 9255a6db..2f5132e5 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -556,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 diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 4ac5509f..6c2ecfd5 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -561,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 diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 509fb802..4b7efc88 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -561,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 diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index e9792ded..36dd9e5e 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -561,6 +561,7 @@ Mostra tastiera Nascondi tastiera Cancellare tutti i dati? + Impossibile cancellare i dati. Non è stato modificato nulla. Cancella Sottoattività Sottoattività diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index e884ffbf..cb7b9a04 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -556,6 +556,7 @@ Toetsenbord tonen Toetsenbord verbergen Alle gegevens wissen? + De gegevens konden niet worden gewist. Er is niets gewijzigd. Wissen Subtaken Subtaak diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 91a70f2e..4a81a22a 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -566,6 +566,7 @@ Pokaż klawiaturę Ukryj klawiaturę Wymazać wszystkie dane? + Nie udało się usunąć danych. Nic nie zostało zmienione. Wymaż Podzadania Podzadanie diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index b47a9e9d..149a2e75 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -561,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 diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index 4104551f..cdbfe3ac 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -566,6 +566,7 @@ Показати клавіатуру Сховати клавіатуру Стерти всі дані? + Не вдалося стерти дані. Нічого не змінено. Стерти Підзавдання Підзавдання diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 1f22a96e..e1b51e0e 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -573,6 +573,7 @@ Show keyboard Hide keyboard Erase all data? + Couldn\'t erase the data. Nothing was changed. Erase Sub-tasks Sub-task 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 index 296d4357..c7e06e27 100644 --- a/app/src/test/java/com/pasich/encly/core/security/DuressPinTest.kt +++ b/app/src/test/java/com/pasich/encly/core/security/DuressPinTest.kt @@ -32,13 +32,15 @@ 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() - auth = AuthenticationManager(store, factor, FakeLockoutClock()) + clock = FakeLockoutClock() + auth = AuthenticationManager(store, factor, clock) assertTrue(auth.configurePin(pin(PIN), dek)) } @@ -123,6 +125,96 @@ class DuressPinTest { 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 @@ -183,7 +275,7 @@ class DuressPinTest { 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()) + assertFalse(auth.duressPinTurnedOff(dek)) } @Test @@ -194,7 +286,7 @@ class DuressPinTest { assertTrue(auth.configurePin(pin(NEW_PIN), dek)) - assertTrue(auth.duressPinTurnedOff()) + assertTrue(auth.duressPinTurnedOff(dek)) assertEquals(PinUnlock.WrongPin, auth.unlockWithPin(pin(DURESS_PIN))) var newVault = false assertEquals( @@ -205,7 +297,7 @@ class DuressPinTest { }, ) assertFalse("the decoy database is kept", newVault) - assertFalse(auth.duressPinTurnedOff()) + assertFalse(auth.duressPinTurnedOff(dek)) assertArrayEquals(decoyDek, (auth.unlockWithPin(pin(DURESS_PIN)) as PinUnlock.Decoy).dek) } @@ -215,7 +307,59 @@ class DuressPinTest { assertTrue(auth.configurePin(pin(NEW_PIN), dek)) - assertFalse(auth.duressPinTurnedOff()) + 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 @@ -329,13 +473,12 @@ class DuressPinTest { assertFalse(vault.security.createBackupKey(vault.seed.generateMnemonic().chars)) assertFalse(vault.security.canResetPinWithoutCurrent()) vault.security.disableBiometric() - vault.security.wipeAndReset() + 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()) - assertTrue(vault.security.getSettingsAuth().isDecoySession) } @Test @@ -462,5 +605,6 @@ class DuressPinTest { 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/SecurityManagerTest.kt b/app/src/test/java/com/pasich/encly/core/security/SecurityManagerTest.kt index 6d519642..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 @@ -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/data/backup/BackupManagerTest.kt b/app/src/test/java/com/pasich/encly/data/backup/BackupManagerTest.kt index 75a8d907..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 @@ -91,6 +92,22 @@ class BackupManagerTest { } } + @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/presentation/viewmodel/BackupViewModelTest.kt b/app/src/test/java/com/pasich/encly/presentation/viewmodel/BackupViewModelTest.kt index d07486ae..649cdc7a 100644 --- a/app/src/test/java/com/pasich/encly/presentation/viewmodel/BackupViewModelTest.kt +++ b/app/src/test/java/com/pasich/encly/presentation/viewmodel/BackupViewModelTest.kt @@ -154,6 +154,7 @@ class BackupViewModelTest { @Test fun eraseNeedsThePinThenRunsAtOnce() { `when`(security.verifyPin(anyCharArray())).thenReturn(true) + `when`(security.wipeAndReset()).thenReturn(true) viewModel.start(BackupAction.ERASE) viewModel.eraseAllData() // not re-authenticated yet: ignored @@ -166,6 +167,20 @@ class BackupViewModelTest { assertTrue(viewModel.uiState.value.erased) } + @Test + fun aRefusedEraseIsReportedAsFailed() { + `when`(security.verifyPin(anyCharArray())).thenReturn(true) + // The security layer refused (a decoy session): nothing was erased. + `when`(security.wipeAndReset()).thenReturn(false) + viewModel.start(BackupAction.ERASE) + + viewModel.reauthFlow.submitPin("123456".toCharArray()) + + waitForStep { it == BackupStep.Idle && viewModel.uiState.value.message != null } + assertEquals(BackupMessage.Text(R.string.security_erase_failed), viewModel.uiState.value.message) + assertEquals(false, viewModel.uiState.value.erased) + } + @Test fun aDeviceWithoutAPickerEndsTheFlowWithAMessage() { viewModel.onSystemPickerUnavailable() diff --git a/app/src/test/java/com/pasich/encly/ui/screens/BackupScreenTest.kt b/app/src/test/java/com/pasich/encly/ui/screens/BackupScreenTest.kt index aeb739db..bb2a49f8 100644 --- a/app/src/test/java/com/pasich/encly/ui/screens/BackupScreenTest.kt +++ b/app/src/test/java/com/pasich/encly/ui/screens/BackupScreenTest.kt @@ -167,4 +167,31 @@ class BackupScreenTest : ComposeScreenTest() { private companion object { const val LONG_WAIT_MS = 15_000L } + + @Test + fun exportInTheDecoyVaultFailsLikeAStorageError() { + app.inDecoySession() + show() + + rule.onNodeWithText(str(R.string.backup_export)).performClick() + waitForText(str(R.string.backup_reauth_title)) + typePin("123456") + + waitForText(str(R.string.backup_error_io)) + assertEquals(BackupStep.Idle, step()) + assertEquals(NavRoutes.BackupRoute.name, currentRoute()) + } + + @Test + fun importInTheDecoyVaultFailsAfterThePhraseLikeAStorageError() { + app.inDecoySession() + show() + openPhraseStep() + + rule.runOnIdle { backup.importFlow.submitPhrase(words.toCharArray()) } + + waitForText(str(R.string.backup_error_io)) + assertEquals(BackupStep.Idle, step()) + assertEquals(NavRoutes.BackupRoute.name, currentRoute()) + } } diff --git a/app/src/test/java/com/pasich/encly/ui/screens/SecurityScreensTest.kt b/app/src/test/java/com/pasich/encly/ui/screens/SecurityScreensTest.kt index 2e68d70e..002c75b2 100644 --- a/app/src/test/java/com/pasich/encly/ui/screens/SecurityScreensTest.kt +++ b/app/src/test/java/com/pasich/encly/ui/screens/SecurityScreensTest.kt @@ -1,6 +1,5 @@ package com.pasich.encly.ui.screens -import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.assertIsEnabled import androidx.compose.ui.test.assertIsNotEnabled @@ -18,13 +17,12 @@ import com.pasich.encly.core.security.BiometricStatus import com.pasich.encly.core.security.DuressPinChange import com.pasich.encly.core.security.PinChange import com.pasich.encly.core.security.WipePinChange -import com.pasich.encly.presentation.navigation.LocalDecoySession import com.pasich.encly.presentation.navigation.NavRoutes -import com.pasich.encly.presentation.navigation.RealVaultOnly import com.pasich.encly.presentation.screen.PinCodeConfigScreen import com.pasich.encly.presentation.screen.settings.DuressPinScreen import com.pasich.encly.presentation.screen.settings.SecuritySettingsScreen import com.pasich.encly.presentation.screen.settings.WipePinScreen +import com.pasich.encly.presentation.viewmodel.BackupAction import com.pasich.encly.presentation.viewmodel.BackupStep import com.pasich.encly.presentation.viewmodel.BackupViewModel import com.pasich.encly.testutil.anyCharArray @@ -425,41 +423,125 @@ class SecurityScreensTest : ComposeScreenTest() { // --- decoy session -------------------------------------------------------------------- + /** What Security lists for a PIN vault with a recovery phrase, in the real vault and the decoy one alike. */ + private val securityRows = listOf( + R.string.security_encryption_active, + R.string.auth_method_pin_title, + R.string.security_biometric_title, + R.string.wipe_pin_title, + R.string.duress_pin_title, + R.string.auto_lock_title, + R.string.backup_needs_phrase_title, + R.string.security_recovery_replace_title, + R.string.security_keyboard_strict_title, + R.string.security_protection_section, + R.string.security_erase_title, + ) + + private fun assertSecurityRowsListed() = securityRows.forEach { + // Section headings are drawn in capitals. + scrollToText(str(it), ignoreCase = true) + assertTrue(str(it), countText(str(it), ignoreCase = true) > 0) + } + + @Test + fun theRealSecurityPageListsTheSharedRows() { + showSecurity(recoveryPhrase = true) + + assertSecurityRowsListed() + } + @Test - fun theDecoySecurityPageListsOnlyAutoLockAndKeyboardPrivacy() { + fun theDecoySecurityPageListsTheSameRowsAsTheRealVault() { app.inDecoySession() - vault = app.backup() - setNavScreen(viewModels(app.securitySettings(), vault), route = NavRoutes.SecuritySettingsRoute.name) { nav -> - SecuritySettingsScreen(nav) - } + showSecurity(recoveryPhrase = true) - waitForText(str(R.string.security_keyboard_strict_title)) - listOf( - R.string.auth_method_pin_title, - R.string.security_biometric_title, - R.string.wipe_pin_title, - R.string.duress_pin_title, - R.string.backup_needs_phrase_title, - R.string.security_erase_title, - R.string.security_encryption_active, - ).forEach { assertEquals(str(it), 0, countText(str(it))) } + assertSecurityRowsListed() } @Test - fun theRealVaultOnlyScreensSendTheDecoySessionToSettings() { + fun aPinChangeInTheDecoyVaultFailsLikeAStorageError() { app.inDecoySession() - setNavScreen( - viewModels(app.securitySettings()), - route = NavRoutes.WipePinRoute.name, - start = NavRoutes.SecuritySettingsRoute.name, - ) { nav -> - CompositionLocalProvider(LocalDecoySession provides { true }) { - RealVaultOnly(nav) { WipePinScreen(nav) } - } - } - navigateTo(NavRoutes.WipePinRoute.name) + showPinChange() + + typePin("123456") + waitForText(str(R.string.pin_create_title)) + typePin("654321") + waitForText(str(R.string.pin_change_confirm_title)) + typePin("654321") + + waitForText(str(R.string.pin_update_failed)) + assertEquals(NavRoutes.PinCodeConfig.name, currentRoute()) + } + + @Test + fun aWipePinInTheDecoyVaultFailsLikeAStorageError() { + app.inDecoySession() + showWipePin() + + typePin("123456") + waitForText(str(R.string.wipe_pin_intro)) + scrollToText(str(R.string.wipe_pin_set)) + rule.onNodeWithText(str(R.string.wipe_pin_set)).performClick() + typePin("864200") + waitForText(str(R.string.wipe_pin_confirm_title)) + typePin("864200") + + waitForText(str(R.string.wipe_pin_failed)) + } + + @Test + fun turningTheWipePinOffInTheDecoyVaultFailsLikeAStorageError() { + app.inDecoySession() + showWipePin() + + typePin("123456") + waitForText(str(R.string.wipe_pin_intro)) + scrollToText(str(R.string.wipe_pin_remove)) + rule.onNodeWithText(str(R.string.wipe_pin_remove)).performClick() + + waitForText(str(R.string.wipe_pin_failed)) + } + + @Test + fun aDuressPinInTheDecoyVaultFailsLikeAStorageError() { + app.inDecoySession() + showDuressPin() + + enterDuressPin(DuressPinChange.FAILED) + + waitForText(str(R.string.duress_pin_failed)) + } + + @Test + fun eraseInTheDecoyVaultFailsAndKeepsTheVault() { + app.inDecoySession() + showSecurity(recoveryPhrase = true) + + rule.runOnIdle { vault.start(BackupAction.ERASE) } + waitForText(str(R.string.backup_reauth_title)) + typePin("123456") + + waitForText(str(R.string.security_erase_failed)) + assertEquals(false, vault.uiState.value.erased) + assertEquals(NavRoutes.SecuritySettingsRoute.name, currentRoute()) + assertTrue(countText(str(R.string.security_erase_title)) > 0) + } + + @Test + fun aNewRecoveryPhraseInTheDecoyVaultFailsAtTheEnd() { + app.inDecoySession(recoveryPhrase = false) + showSecurity(recoveryPhrase = false) + + rule.onNodeWithText(str(R.string.security_recovery_missing)).performClick() + waitForText(str(R.string.backup_reauth_title)) + typePin("123456") + waitFor(describe = { "${vault.uiState.value}" }) { vault.uiState.value.step is BackupStep.ShowNewPhrase } + rule.onNodeWithText(str(R.string.backup_phrase_written)).performClick() + waitFor(describe = { "${vault.uiState.value}" }) { vault.uiState.value.step is BackupStep.CheckNewPhrase } + val step = vault.uiState.value.step as BackupStep.CheckNewPhrase + rule.runOnIdle { vault.phraseFlow.submitCheck(step.expected) } - waitFor { currentRoute() == NavRoutes.SettingsRoute.name } - assertEquals(0, countText(str(R.string.wipe_pin_current_subtitle))) + waitForText(str(R.string.backup_error_phrase_setup)) } } diff --git a/app/src/test/java/com/pasich/encly/ui/screens/SettingsScreensTest.kt b/app/src/test/java/com/pasich/encly/ui/screens/SettingsScreensTest.kt index ae795671..4d2c8010 100644 --- a/app/src/test/java/com/pasich/encly/ui/screens/SettingsScreensTest.kt +++ b/app/src/test/java/com/pasich/encly/ui/screens/SettingsScreensTest.kt @@ -1,7 +1,6 @@ package com.pasich.encly.ui.screens import androidx.compose.material3.MaterialTheme -import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.SideEffect import androidx.compose.ui.graphics.Color import androidx.compose.ui.semantics.SemanticsProperties @@ -15,7 +14,6 @@ import com.pasich.encly.core.locale.AppLocales import com.pasich.encly.domain.model.FontStyleType import com.pasich.encly.domain.model.ThemePalette import com.pasich.encly.domain.model.ThemeType -import com.pasich.encly.presentation.navigation.LocalDecoySession import com.pasich.encly.presentation.navigation.NavRoutes import com.pasich.encly.presentation.screen.settings.AppearanceScreen import com.pasich.encly.presentation.screen.settings.SettingsScreen @@ -67,15 +65,20 @@ class SettingsScreensTest : ComposeScreenTest() { } @Test - fun theDecoyVaultHasNoBackupEntry() { - setNavScreen(viewModels(app.settingsVm()), route = NavRoutes.SettingsRoute.name) { nav -> - CompositionLocalProvider(LocalDecoySession provides { true }) { SettingsScreen(nav) } - } + fun theDecoyVaultListsTheSameSectionsAsTheRealOne() { + app.inDecoySession() + showSettings() - scrollToText(str(R.string.settings_privacy), ignoreCase = true) - scrollToText(str(R.string.settings_appearance)) - assertEquals(0, countText(str(R.string.settings_backup), ignoreCase = true)) - assertEquals(0, countText(str(R.string.backup_title))) + listOf( + R.string.settings_general, + R.string.settings_notes_and_tasks, + R.string.settings_privacy, + R.string.settings_backup, + ).forEach { scrollToText(str(it), ignoreCase = true) } + scrollToText(str(R.string.backup_title)) + rule.onNodeWithText(str(R.string.backup_title)).performClick() + + waitFor { currentRoute() == NavRoutes.BackupRoute.name } } @Test diff --git a/app/src/test/java/com/pasich/encly/ui/screens/TestApp.kt b/app/src/test/java/com/pasich/encly/ui/screens/TestApp.kt index fc05af20..6b50c0c8 100644 --- a/app/src/test/java/com/pasich/encly/ui/screens/TestApp.kt +++ b/app/src/test/java/com/pasich/encly/ui/screens/TestApp.kt @@ -11,10 +11,12 @@ import com.pasich.encly.core.security.AuthSettings import com.pasich.encly.core.security.AuthType import com.pasich.encly.core.security.AutoLock 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.SessionLockManager +import com.pasich.encly.core.security.WipePinChange import com.pasich.encly.data.backup.BackupDocuments import com.pasich.encly.data.backup.BackupManager import com.pasich.encly.data.backup.BackupMapper @@ -81,6 +83,7 @@ internal class TestApp(context: Context) { // The app is in the foreground, so a completed unlock is published, not re-locked. sessionLock.onStart(mock(androidx.lifecycle.LifecycleOwner::class.java)) withAuth(recoveryPhrase = false) + `when`(security.wipeAndReset()).thenReturn(true) } private val appScope = CoroutineScope(SupervisorJob() + Dispatchers.Main) @@ -109,13 +112,22 @@ internal class TestApp(context: Context) { } } - /** The decoy vault is open: Security lists nothing of the real vault. */ - fun inDecoySession() = apply { - `when`(security.getSettingsAuth()).thenReturn( - AuthSettings(AuthType.PIN, false, false, isDecoySession = true), - ) - `when`(security.biometricStatus()).thenReturn(BiometricStatus.UNAVAILABLE) + /** + * The decoy vault is open. Settings looks as it does in the real vault ([recoveryPhrase] is + * the real vault's state), the duress PIN passes a PIN re-check, and the security layer + * refuses every real-vault action the way it does in the app: a PIN, wipe PIN or duress PIN + * change fails, an erase does nothing (the vault stays open), and backups throw. + */ + fun inDecoySession(recoveryPhrase: Boolean = true) = apply { + withAuth(recoveryPhrase) `when`(security.isDecoySession()).thenReturn(true) + `when`(security.verifyPin(anyCharArray())).thenReturn(true) + `when`(security.hasBackupKey()).thenReturn(true) + `when`(security.isDatabaseUnlocked()).thenReturn(true) + `when`(security.wipeAndReset()).thenReturn(false) + `when`(security.changePin(anyCharArray())).thenReturn(PinChange.FAILED) + `when`(security.configureWipePin(anyCharArray())).thenReturn(WipePinChange.FAILED) + `when`(security.configureDuressPin(anyCharArray())).thenReturn(DuressPinChange.FAILED) } /** The vault's stored security settings, as the Security page reads them. */ diff --git a/docs/architecture.md b/docs/architecture.md index 63690879..cbb7b79d 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -44,7 +44,7 @@ and resolve DAOs through it at operation time, so after a re-lock (or an unlock vault) they fail instead of reading or writing a database they were not created for. `SecurityManager` records which vault the session opened and refuses every operation on the real vault's key slots (PIN, wipe and duress PINs, biometric, recovery, backup key) in a decoy -session; Settings leaves those screens out. +session; Settings still lists them, and they fail like a storage error. ## Notes and tasks From 7b5074155163e9224cc4d80150a53f29cd7a2888 Mon Sep 17 00:00:00 2001 From: pasichdev Date: Thu, 15 Oct 2026 13:11:10 +0300 Subject: [PATCH 11/18] fix(import): keep media out of the limits and fix review findings Import: - Count media and attachments by name without keeping them: picked files are never opened, TAR members are discarded and ZIP members are inflated against a separate 2 GiB cap, so a Takeout full of photos imports and a bomb named photo.jpg still stops. - Key Markdown notes by path and content, accept front matter only with a key line, strip only known HTML tags, keep Lexical code and tables as text, make the wiki-link match linear, treat unknown ZIP signatures as errors, read backslash paths and CP437 names, count every archive entry and too-deep file, and apply Notesnook rules per file. Editor: - Keep the first pasted line as plain text after existing text, paste empty checklist items, keep a heading or quote tail's kind, stop offering templates once a new note is saved, and observe the slash menu only in the paragraph that owns it. SECURITY.md and CHANGELOG.md cover these and the duress PIN lockout. Refs: #61 Refs: #62 Refs: #64 --- CHANGELOG.md | 9 +- SECURITY.md | 65 ++++--- .../pasich/encly/data/handoff/InlineHtml.kt | 18 +- .../encly/data/importer/DocumentTree.kt | 28 ++-- .../encly/data/importer/FormatDetector.kt | 13 +- .../pasich/encly/data/importer/Frontmatter.kt | 9 +- .../encly/data/importer/ImportMapper.kt | 62 +++++-- .../encly/data/importer/ImportReader.kt | 145 ++++++++++++---- .../pasich/encly/data/importer/ImportTypes.kt | 10 +- .../encly/data/importer/LexicalBlocks.kt | 20 ++- .../data/importer/MarkdownBlockMapper.kt | 43 +++-- .../data/importer/MarkdownNotesParser.kt | 30 +++- .../encly/data/importer/ZipStreamReader.kt | 48 +++++- .../editor/blocks/SlashMenuPopup.kt | 23 ++- .../presentation/editor/state/PasteEditing.kt | 30 +++- .../presentation/editor/state/SmartPaste.kt | 11 +- .../screen/editnote/EditNoteLocalProvider.kt | 3 + .../viewmodel/EditNoteViewModel.kt | 7 +- .../viewmodel/ImportNotesViewModel.kt | 94 ++++++----- app/src/main/res/values-de/strings_import.xml | 2 +- app/src/main/res/values-es/strings_import.xml | 2 +- app/src/main/res/values-fr/strings_import.xml | 2 +- app/src/main/res/values-it/strings_import.xml | 2 +- app/src/main/res/values-nl/strings_import.xml | 2 +- app/src/main/res/values-pl/strings_import.xml | 2 +- app/src/main/res/values-pt/strings_import.xml | 2 +- app/src/main/res/values-uk/strings_import.xml | 2 +- app/src/main/res/values/strings_import.xml | 2 +- .../encly/data/handoff/InlineHtmlTest.kt | 13 ++ .../encly/data/importer/FrontmatterTest.kt | 24 +++ .../encly/data/importer/ImportMapperTest.kt | 43 +++++ .../encly/data/importer/ImportReaderTest.kt | 158 +++++++++++++++++- .../data/importer/MarkdownBlockMapperTest.kt | 43 ++++- .../encly/data/importer/TextParsersTest.kt | 21 +++ .../data/importer/ZipStreamReaderTest.kt | 46 +++++ .../editor/state/SmartPasteTest.kt | 85 +++++++++- .../viewmodel/EditNoteViewModelComfortTest.kt | 29 +++- .../viewmodel/ImportNotesViewModelTest.kt | 29 ++++ config/detekt/detekt.yml | 2 + 39 files changed, 971 insertions(+), 208 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 221f5e75..c81a3502 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,14 +15,16 @@ IzzyOnDroid) and used as the GitHub Release notes. - 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. + 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. - Nothing is rewritten or dropped, and one undo takes the whole paste back. + 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 @@ -33,7 +35,8 @@ IzzyOnDroid) and used as the GitHub Release notes. - 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. It does not hide the real vault from a forensic + 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). ## [2.1.0] - 2026-10-05 diff --git a/SECURITY.md b/SECURITY.md index 817cef07..82babb4d 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -50,7 +50,9 @@ decoy DEK (random 256 bit) ── SQLCipher raw key of the decoy vault (vault_b. 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. @@ -70,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). @@ -158,27 +162,39 @@ meant for "open the app and show me", not against a forensic examination. "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.** 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. +- **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 clears it like the - PIN. + 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) turns the duress PIN off like the wipe PIN, and - Settings says so in the real vault; setting it again opens the same decoy notes. + 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. Everything that belongs to the real vault is refused by the security layer and left - out of Settings: changing the PIN, the wipe and duress PINs, fingerprint unlock, the + 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 cannot be used to test guesses of the - real PIN. Fingerprint unlock and the recovery phrase on the lock screen always open the real + 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 @@ -387,21 +403,28 @@ generic Markdown/TXT (Obsidian, Markor). - **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: 64 MiB read, 64 MiB uncompressed, 4 MiB per file (16 MiB for - a JSON export that holds every note), 20 000 entries (directories included), 20 000 notes, path - depth 16, JSON nesting 64. Absolute paths, `..`, backslashes, NUL, links, devices and archives - inside archives are skipped and counted; a ZIP bomb or an endless archive ends as "too large". + 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; nothing is executed or - rendered as HTML, and frontmatter is read for a few known keys only. + 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, so - a repeated import adds nothing), tags matched by name. + 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. 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 index 149a0362..0c1e84dd 100644 --- a/app/src/main/java/com/pasich/encly/data/importer/DocumentTree.kt +++ b/app/src/main/java/com/pasich/encly/data/importer/DocumentTree.kt @@ -18,9 +18,11 @@ interface DocumentTree { } /** - * Walks a [DocumentTree] lazily into [SourceFile]s with paths relative to its root, never deeper - * than [ImportLimits.maxPathDepth] and never listing more than [ImportLimits.maxEntries] entries in - * all, so a cyclic or enormous tree ends instead of looping. + * 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 { @@ -31,19 +33,13 @@ class TreeWalker(private val tree: DocumentTree, private val limits: ImportLimit private var entriesLeft = 0 private fun files(parentId: String, prefix: String, depth: Int): Sequence = sequence { - if (depth <= limits.maxPathDepth) { - for (node in tree.children(parentId).take(limits.maxEntries)) { - if (entriesLeft-- <= 0) break - val path = if (prefix.isEmpty()) node.name else "$prefix/${node.name}" - if (node.isDirectory) { - yieldAll(files(node.id, path, depth + 1)) - } else { - yield( - SourceFile(path) { - tree.open(node.id) - }, - ) - } + 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 index 2db9bd39..bd8578fb 100644 --- a/app/src/main/java/com/pasich/encly/data/importer/FormatDetector.kt +++ b/app/src/main/java/com/pasich/encly/data/importer/FormatDetector.kt @@ -157,12 +157,21 @@ internal object ImportKinds { ) /** A hidden file or folder (`.obsidian/`, `.DS_Store`) or macOS metadata (`__MACOSX/`). */ - fun isHidden(entry: ImportEntry): Boolean = entry.path.split('/').any { it.startsWith(".") || it == "__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 = entry.extension in IGNORABLE && entry.extension !in MARKUP + 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") 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 index 17b436d8..04a7eee9 100644 --- a/app/src/main/java/com/pasich/encly/data/importer/Frontmatter.kt +++ b/app/src/main/java/com/pasich/encly/data/importer/Frontmatter.kt @@ -30,7 +30,8 @@ class MarkdownDocument(private val meta: Map>, val body: St * 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. + * [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 @@ -47,13 +48,17 @@ object Frontmatter { } else { null } - return if (end == 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 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 index 1cda7a2a..622d1456 100644 --- a/app/src/main/java/com/pasich/encly/data/importer/ImportMapper.kt +++ b/app/src/main/java/com/pasich/encly/data/importer/ImportMapper.kt @@ -44,7 +44,8 @@ class ImportPlan(val payload: BackupPayload, val preview: ImportPreview, val row * 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, path, Keep path + creation time), else of the normalized title and text. + * (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]). @@ -62,6 +63,7 @@ object ImportMapper { 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() @@ -75,7 +77,7 @@ object ImportMapper { duplicates++ continue } - val tagUid = note.tags.firstNotNullOfOrNull { tag(it, tags) } + val tagUid = note.tags.firstNotNullOfOrNull { tag(it, tags, tagNames) } val edited = note.edited ?: note.created ?: now notes += BackupNote( uid = uid, @@ -87,7 +89,7 @@ object ImportMapper { tagUid = tagUid, isTrash = note.trashed, ) - rows += ImportReview.row(uid, note, tagUid?.let { id -> tags.values.first { it.uid == id }.name }, edited) + 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++ @@ -115,13 +117,15 @@ object ImportMapper { return ImportPlan(payload, preview, rows) } - private fun tag(raw: String, tags: MutableMap): String? { + 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) - return tags.getOrPut(key) { + val tag = tags.getOrPut(key) { BackupTag(uid = sha256Hex("encly-import/v1|tag|$key"), name = name, visible = true, position = tags.size) - }.uid + } + names[tag.uid] = tag.name + return tag.uid } private fun uidFor(source: ImportSource, note: ImportedNote): String { @@ -136,7 +140,13 @@ object ImportMapper { private fun normalized(text: String): String = text.trim().replace(WHITESPACE, " ").lowercase() - private fun textOf(note: ImportedNote): String = note.blocks.joinToString("\n") { block -> + /** 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 @@ -191,7 +201,7 @@ object ImportAnalyzer { is Detection.Encrypted -> Analysis.Encrypted(detection.source) is Detection.Found -> try { - Analysis.Ready(plan(detection.source, read, kinds, limits, now)) + Analysis.Ready(plan(detection.source, read, classified(kinds, forced), limits, now)) } catch (e: ImportException) { Analysis.Failed(e.error) } @@ -205,6 +215,28 @@ object ImportAnalyzer { 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, @@ -219,17 +251,15 @@ object ImportAnalyzer { (kinds[index] == EntryKind.IGNORED && !ImportKinds.isHidden(entry) && ImportKinds.isMedia(entry)) } val encrypted = kinds.count { it == EntryKind.STANDARD_NOTES_ENCRYPTED || it == EntryKind.NOTESNOOK_BACKUP } - val parsed = when (source) { - ImportSource.GOOGLE_KEEP -> KeepParser.parse(usable, limits) - ImportSource.STANDARD_NOTES -> StandardNotesParser.parse(usable, limits) - ImportSource.JOPLIN -> JoplinParser.parse(usable, limits) - ImportSource.NOTESNOOK -> MarkdownNotesParser.parse(usable, limits, notesnook = true) - ImportSource.MARKDOWN -> MarkdownNotesParser.parse(usable, limits, notesnook = false) - } + // 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), - parsed.extraLosses, + 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) 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 index 94026e1d..ceea0094 100644 --- a/app/src/main/java/com/pasich/encly/data/importer/ImportReader.kt +++ b/app/src/main/java/com/pasich/encly/data/importer/ImportReader.kt @@ -6,16 +6,22 @@ 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. */ -class SourceFile(val path: String, val open: () -> InputStream?) +/** + * 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. */ -class ReadResult(val entries: List, val skips: ReadSkips) +/** + * 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]. @@ -26,15 +32,26 @@ class ReadResult(val entries: List, val skips: ReadSkips) * 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 @@ -45,27 +62,41 @@ class ImportReader(private val limits: ImportLimits = ImportLimits()) { fun run(source: ImportFileSource): ReadResult { for (file in source.files()) { checkCancelled() - val stream = try { - file.open() - } catch (_: IOException) { - null - } catch (_: SecurityException) { - null - } - if (stream == null) { - unreadable++ - continue - } - try { - stream.use { process(file.path, Counting(it)) } - } catch (_: IOException) { - unreadable++ - } catch (_: IllegalArgumentException) { - // A ZIP entry name that is not valid text. - unreadable++ - } + 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++ } - return ReadResult(entries, ReadSkips(unsafe, nested, tooLarge, unreadable)) } private fun process(path: String, input: InputStream) { @@ -73,8 +104,26 @@ class ImportReader(private val limits: ImportLimits = ImportLimits()) { val whole = SequenceInputStream(ByteArrayInputStream(head), input) when { isZip(head) -> readZip(whole) + TarReader.looksLikeTar(head) -> readTar(whole) - else -> accept(path, 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++ } } @@ -95,7 +144,17 @@ class ImportReader(private val limits: ImportLimits = ImportLimits()) { while (true) { checkCancelled() val entry = zip.next() ?: return - if (entry.isDirectory) skipDirectory(zip.data()) else accept(entry.name, zip.data()) + countEntry() + when { + entry.isDirectory -> skipDirectory(zip.data()) + + ImportKinds.isMedia(entry.name) -> { + attachment(entry.name) + drain(zip.data()) + } + + else -> accept(entry.name, zip.data()) + } } } @@ -104,10 +163,15 @@ class ImportReader(private val limits: ImportLimits = ImportLimits()) { while (true) { checkCancelled() val header = tar.next() ?: return + countEntry() when (header.type) { TarReader.DIRECTORY -> Unit - TarReader.REGULAR, TarReader.REGULAR_OLD -> accept(header.path, tar.data()) + // 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++ @@ -117,7 +181,6 @@ class ImportReader(private val limits: ImportLimits = ImportLimits()) { /** 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) { - if (++seen > limits.maxEntries) throw ImportException(ImportError.TOO_LARGE) val path = safePath(rawPath, limits.maxPathDepth) val cap = if (path != null && isContainerName(path)) limits.maxContainerBytes else limits.maxEntryBytes val bytes = readBounded(input, cap) @@ -134,11 +197,21 @@ class ImportReader(private val limits: ImportLimits = ImportLimits()) { * against the total, so many directories with data cannot inflate past the limits. */ private fun skipDirectory(input: InputStream) { - if (++seen > limits.maxEntries) throw ImportException(ImportError.TOO_LARGE) // 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() @@ -190,17 +263,19 @@ class ImportReader(private val limits: ImportLimits = ImportLimits()) { } /** - * [raw] as a relative path with `.` and empty segments removed, or null if it is unsafe: - * absolute, a drive letter, `..`, a backslash or NUL, or deeper than [maxDepth]. + * [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 segments = raw.split('/').filter { it.isNotEmpty() && it != "." } - val unsafe = raw.any { it == '\u0000' || it == '\\' } || raw.startsWith("/") || - DRIVE.containsMatchIn(raw) || segments.isEmpty() || segments.size > maxDepth || ".." in segments + 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 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] } 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 index f3ba9973..ce97b006 100644 --- a/app/src/main/java/com/pasich/encly/data/importer/ImportTypes.kt +++ b/app/src/main/java/com/pasich/encly/data/importer/ImportTypes.kt @@ -69,10 +69,12 @@ class ImportException(val error: ImportError) : Exception(error.name) * so a ZIP bomb stops at the limit. */ data class ImportLimits( - /** Bytes read from the selected files in total (compressed bytes count as read). */ - val maxInputBytes: Long = MAX_TOTAL_BYTES, - /** Bytes after decompression, over every entry (skipped ones included). */ + /** 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). */ @@ -86,6 +88,8 @@ data class ImportLimits( 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 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 index b8c09fdb..8498c8db 100644 --- a/app/src/main/java/com/pasich/encly/data/importer/LexicalBlocks.kt +++ b/app/src/main/java/com/pasich/encly/data/importer/LexicalBlocks.kt @@ -8,7 +8,8 @@ import kotlinx.serialization.json.JsonObject /** * A Standard Notes Super note: Lexical JSON read for its text, in order. Paragraphs, headings, - * quotes, rules and (flattened) lists become blocks; any other node contributes its text. + * 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() @@ -23,6 +24,8 @@ internal class LexicalBlocks private constructor() { "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) @@ -31,10 +34,13 @@ internal class LexicalBlocks private constructor() { private fun other(node: JsonObject, depth: Int) { val children = children(node) - if (children.isEmpty()) { - add(ImportText.string(node["text"]).orEmpty(), ImportBlocks::text) - } else { - children.forEach { block(it, depth + 1) } + 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) } } } @@ -66,8 +72,9 @@ internal class LexicalBlocks private constructor() { fun walk(current: JsonObject, depth: Int) { val skipped = depth > MAX_DEPTH when (ImportText.string(current["type"])) { - "text" -> append(ImportText.string(current["text"]).orEmpty()) + "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) } } @@ -81,6 +88,7 @@ internal class LexicalBlocks private constructor() { 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? { 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 index 700ab3c0..040edffa 100644 --- a/app/src/main/java/com/pasich/encly/data/importer/MarkdownBlockMapper.kt +++ b/app/src/main/java/com/pasich/encly/data/importer/MarkdownBlockMapper.kt @@ -6,7 +6,7 @@ import com.pasich.encly.dynamicBlocks.Block import com.pasich.encly.dynamicBlocks.BlockType /** - * Markdown as Encly blocks. Line based and forgiving; text is never dropped. + * 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); @@ -15,17 +15,21 @@ import com.pasich.encly.dynamicBlocks.BlockType * (`> [!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, so no raw markup reaches the editor. + * ([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]*""") - private val WIKI_LINK = Regex("""!?\[\[([^\]|\n]+)(?:\|([^\]\n]+))?]]""") + + // 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]+(.*)$""") - private val NUMBER = Regex("""^[ \t]*\d{1,9}[.)][ \t]+(.*)$""") - private val PASTED_NUMBER = Regex("""^[ \t]*\d{1,3}[.)][ \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}(```|~~~).*$""") @@ -37,14 +41,18 @@ object MarkdownBlockMapper { /** * 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 a number only starts a list when it has up to three digits (so - * "2024. Report" stays text). [claim] may take a line whole as a block of its own (a lone web - * address); it runs before every other rule. [toBlocks] is unchanged. + * 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, claim: (String) -> Block? = { null }): List = - Builder(PasteRules(claim)).apply { text.lines().forEach(::line) }.finish() + 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?) + private class PasteRules(val claim: (String) -> Block?, var plainFirstLine: Boolean) /** [text] with HTML removed outside code spans. */ internal fun inline(text: String): String { @@ -61,6 +69,7 @@ object MarkdownBlockMapper { /** 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) { @@ -73,6 +82,13 @@ object MarkdownBlockMapper { 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 -> { @@ -109,7 +125,7 @@ object MarkdownBlockMapper { val check = CHECK.matchEntire(raw) val glyph = GLYPH_ITEM.matchEntire(raw) val bullet = BULLET.matchEntire(raw) - val number = (if (paste != null) PASTED_NUMBER else NUMBER).matchEntire(raw) + val number = NUMBER.matchEntire(raw) val quoted = QUOTE.matchEntire(raw) val claimed = paste?.claim?.invoke(raw) when { @@ -206,7 +222,8 @@ object MarkdownBlockMapper { private fun flushList() { val type = listType ?: return - val kept = items.filter { it.value.isNotBlank() } + // 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 index 93f6ab77..fb02fa68 100644 --- a/app/src/main/java/com/pasich/encly/data/importer/MarkdownNotesParser.kt +++ b/app/src/main/java/com/pasich/encly/data/importer/MarkdownNotesParser.kt @@ -13,6 +13,11 @@ package com.pasich.encly.data.importer * - 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 { @@ -24,15 +29,29 @@ internal object MarkdownNotesParser { fun isTextFile(entry: ImportEntry): Boolean = entry.extension in TEXT_EXTENSIONS - fun parse(entries: List, limits: ImportLimits, notesnook: Boolean): ParsedNotes { + /** + * [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 { - (notesnook && ATTACHMENTS_DIR in dirs) || ImportKinds.isMedia(entry) -> attachments++ - isTextFile(entry) -> context.text(entry)?.let { context.add(note(entry, it, dirs, notesnook)) } + (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++ } } @@ -48,10 +67,11 @@ internal object MarkdownNotesParser { 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, + sourceKey = entry.path + "|" + ImportMapper.contentDigest(titled.title, blocks), title = titled.title, - blocks = MarkdownBlockMapper.toBlocks(titled.body), + blocks = blocks, tags = tags, created = ImportTime.parse(doc.firstValue("created_at", "created", "date")), edited = ImportTime.parse(doc.firstValue("updated_at", "updated", "modified")), 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 index 2f6ad891..c46adc58 100644 --- a/app/src/main/java/com/pasich/encly/data/importer/ZipStreamReader.kt +++ b/app/src/main/java/com/pasich/encly/data/importer/ZipStreamReader.kt @@ -2,6 +2,10 @@ 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 @@ -15,7 +19,8 @@ import java.util.zip.Inflater * 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; paths are validated by the caller, never here. + * 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") @@ -37,13 +42,21 @@ internal class ZipStreamReader(private val input: InputStream) { private var lim = 0 private var state: State? = null - /** The next entry, or null at the central directory or the end; unread data of the last one is dropped. */ + /** + * 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() - val header = if (signature == LOCAL_HEADER) readExact(LOCAL_FIXED) else null - return header?.let(::open) + 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. */ @@ -64,7 +77,7 @@ internal class ZipStreamReader(private val input: InputStream) { ) { throw IOException("unsupported zip entry") } - val name = String(readExact(u16(header, NAME_LENGTH)), Charsets.UTF_8) + 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) @@ -76,6 +89,23 @@ internal class ZipStreamReader(private val input: InputStream) { 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 @@ -239,10 +269,18 @@ internal class ZipStreamReader(private val input: InputStream) { } 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 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 index f1ead231..de2e191e 100644 --- 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 @@ -6,6 +6,7 @@ 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 @@ -30,7 +31,9 @@ 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 @@ -41,6 +44,17 @@ import kotlinx.coroutines.flow.first */ @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() @@ -78,9 +92,16 @@ internal fun SlashMenuPopup(field: TextFieldState, slash: SlashActions, 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 { +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) { 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 index c9e268d0..d22c94c6 100644 --- 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 @@ -8,20 +8,29 @@ 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. Whitespace-only - * lines at either end of the paste are dropped; everything else is kept. One undo step. + * 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) - val mapped = pastedBlocks(pasted) + // 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 { - mapped.isEmpty() -> { + pasted.isEmpty() -> { // Nothing but blank lines: no content to paste. selection.focusAt(index, caret = lineBreak.head.length) lineBreak.head + lineBreak.tail } - mapped.singleOrNull() is Block.TextBlock -> pasteParagraph(index, block, lineBreak, pasted) + // 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) } @@ -63,11 +72,11 @@ private fun BlockEditorState.pasteBlocks( // The pasted text goes on into what followed the cursor. val last = rest.last() as? Block.TextBlock if (last == null) { - rest = rest + Block.TextBlock(MutableStateFlow(tail)) + rest = rest + block.sameKind(tail) } else { val joined = last.text.value + pasted.last().takeLastWhile { it.isWhitespace() } caret = joined.length - rest = rest.dropLast(1) + Block.TextBlock(MutableStateFlow(joined + tail)) + rest = rest.dropLast(1) + block.sameKind(joined + tail) } } // Nothing before the cursor and nothing to join: the pasted blocks take the block's place. @@ -88,3 +97,10 @@ private fun BlockEditorState.pasteBlocks( /** 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/SmartPaste.kt b/app/src/main/java/com/pasich/encly/presentation/editor/state/SmartPaste.kt index 59227ccd..fd738a0c 100644 --- 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 @@ -24,11 +24,14 @@ fun trimBlankLines(lines: List): List { * 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. + * 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): List { - val text = trimBlankLines(lines).joinToString("\n", transform = ::asMarkdownMark) - return MarkdownBlockMapper.pasteBlocks(text, claim = ::pastedAddress) +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. */ 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 ad20ec2d..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 @@ -10,6 +10,8 @@ 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 @@ -70,6 +72,7 @@ fun EditNoteSettingsProvider( 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/viewmodel/EditNoteViewModel.kt b/app/src/main/java/com/pasich/encly/presentation/viewmodel/EditNoteViewModel.kt index cbb8a8d5..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 @@ -522,14 +522,15 @@ constructor( /** * 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. + * 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() }.distinctUntilChanged(), + persistence.state.map { it.note.title.isEmpty() && it.note.id <= 0L }.distinctUntilChanged(), editor.contentChanges.map { editor.isBlankNote() }.onStart { emit(editor.isBlankNote()) }, simpleEdit, _lockEditor, - ) { blankTitle, blankBody, simple, locked -> isNewNote && blankTitle && blankBody && !simple && !locked } + ) { blankUnsaved, blankBody, simple, locked -> isNewNote && blankUnsaved && blankBody && !simple && !locked } .distinctUntilChanged() .stateIn(viewModelScope, SharingStarted.WhileSubscribed(STOP_TIMEOUT_MS), false) 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 index 5958cf87..8a5f04a6 100644 --- a/app/src/main/java/com/pasich/encly/presentation/viewmodel/ImportNotesViewModel.kt +++ b/app/src/main/java/com/pasich/encly/presentation/viewmodel/ImportNotesViewModel.kt @@ -171,10 +171,27 @@ class ImportNotesViewModel @Inject constructor( } bound = store _step.value = ImportStep.Reading - job = viewModelScope.launch { _step.value = read(store, source) } + job = viewModelScope.launch { settle(read(store, source)) } } - private suspend fun read(store: VaultDataStore, source: ImportFileSource): ImportStep = try { + /** + * 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() } @@ -183,44 +200,41 @@ class ImportNotesViewModel @Inject constructor( } catch (e: CancellationException) { throw e } catch (e: ImportException) { - failed(failureOf(e.error)) + failure(failureOf(e.error)) } catch (_: IOException) { - failed(ImportFailure.UNREADABLE) + failure(ImportFailure.UNREADABLE) } catch (_: SecurityException) { - failed(ImportFailure.UNREADABLE) + failure(ImportFailure.UNREADABLE) } catch (_: OutOfMemoryError) { - failed(ImportFailure.TOO_LARGE) + failure(ImportFailure.TOO_LARGE) } catch (_: RuntimeException) { // The vault closed while reading, or the snapshot failed. The cause can quote content. - failed(ImportFailure.VAULT) + failure(ImportFailure.VAULT) } - private suspend fun show(analysis: Analysis, read: ReadResult, store: VaultDataStore): ImportStep = - when (analysis) { - is Analysis.Ready -> { - val existing = store.snapshot().notes.mapTo(HashSet()) { it.uid } - val ready = analysis.plan.withExisting(existing) - plan = ready - pending = null - ImportStep.Preview(ready.preview, ReviewState.of(ready.rows)) - } + private fun failure(failure: ImportFailure) = Outcome(ImportStep.Failed(failure)) - Analysis.Ambiguous -> { - // Kept in memory so the user's choice can be applied without reading again. - pending = read - ImportStep.ChooseSource - } + /** 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) + } - is Analysis.Encrypted -> failed( - if (analysis.source == ImportSource.NOTESNOOK) { - ImportFailure.ENCRYPTED_NOTESNOOK - } else { - ImportFailure.ENCRYPTED_STANDARD_NOTES - }, - ) + // Kept in memory so the user's choice can be applied without reading again. + Analysis.Ambiguous -> Outcome(ImportStep.ChooseSource, pending = read) - is Analysis.Failed -> failed(failureOf(analysis.error)) - } + 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) { @@ -229,15 +243,17 @@ class ImportNotesViewModel @Inject constructor( val store = bound ?: return _step.value = ImportStep.Reading job = viewModelScope.launch { - _step.value = try { - withContext(ioDispatcher) { show(ImportAnalyzer.analyze(read, forced = source), read, store) } - } catch (e: CancellationException) { - throw e - } catch (e: ImportException) { - failed(failureOf(e.error)) - } catch (_: RuntimeException) { - failed(ImportFailure.VAULT) - } + 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) + }, + ) } } diff --git a/app/src/main/res/values-de/strings_import.xml b/app/src/main/res/values-de/strings_import.xml index 7817bd51..aa62e595 100644 --- a/app/src/main/res/values-de/strings_import.xml +++ b/app/src/main/res/values-de/strings_import.xml @@ -59,7 +59,7 @@ 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 oder 20.000 Notizen). Importiere sie in kleineren Teilen. + 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. diff --git a/app/src/main/res/values-es/strings_import.xml b/app/src/main/res/values-es/strings_import.xml index 2bbdc515..3313d0eb 100644 --- a/app/src/main/res/values-es/strings_import.xml +++ b/app/src/main/res/values-es/strings_import.xml @@ -59,7 +59,7 @@ 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 o 20 000 notas). Impórtala por partes. + 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. diff --git a/app/src/main/res/values-fr/strings_import.xml b/app/src/main/res/values-fr/strings_import.xml index 39c8ad72..da70ab91 100644 --- a/app/src/main/res/values-fr/strings_import.xml +++ b/app/src/main/res/values-fr/strings_import.xml @@ -59,7 +59,7 @@ 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 ou 20 000 notes). Importez-la en plusieurs parties. + 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. diff --git a/app/src/main/res/values-it/strings_import.xml b/app/src/main/res/values-it/strings_import.xml index c7e8f4c7..8061f494 100644 --- a/app/src/main/res/values-it/strings_import.xml +++ b/app/src/main/res/values-it/strings_import.xml @@ -59,7 +59,7 @@ 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 o 20.000 note). Importala a parti. + 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. diff --git a/app/src/main/res/values-nl/strings_import.xml b/app/src/main/res/values-nl/strings_import.xml index f4a427c1..32d0ece5 100644 --- a/app/src/main/res/values-nl/strings_import.xml +++ b/app/src/main/res/values-nl/strings_import.xml @@ -59,7 +59,7 @@ 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 of 20.000 notities). Importeer hem in delen. + 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. diff --git a/app/src/main/res/values-pl/strings_import.xml b/app/src/main/res/values-pl/strings_import.xml index b8ecd596..df2a0c2c 100644 --- a/app/src/main/res/values-pl/strings_import.xml +++ b/app/src/main/res/values-pl/strings_import.xml @@ -59,7 +59,7 @@ 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 lub 20 000 notatek). Zaimportuj go w częściach. + 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ć. diff --git a/app/src/main/res/values-pt/strings_import.xml b/app/src/main/res/values-pt/strings_import.xml index f91acdb7..aa78f301 100644 --- a/app/src/main/res/values-pt/strings_import.xml +++ b/app/src/main/res/values-pt/strings_import.xml @@ -59,7 +59,7 @@ 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 ou 20.000 notas). Importe em partes. + 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. diff --git a/app/src/main/res/values-uk/strings_import.xml b/app/src/main/res/values-uk/strings_import.xml index afe11d74..dd0928bd 100644 --- a/app/src/main/res/values-uk/strings_import.xml +++ b/app/src/main/res/values-uk/strings_import.xml @@ -59,7 +59,7 @@ Ця резервна копія Standard Notes зашифрована. Завантажте в Standard Notes розшифровану копію й оберіть її. Це зашифрована резервна копія Notesnook. Експортуйте нотатки в Notesnook як Markdown і оберіть .zip. Ці файли не є підтримуваним експортом. Перегляньте підтримувані застосунки або оберіть файли Markdown чи текстові. - Цей вибір більший, ніж Encly може імпортувати за раз (64 МБ або 20 000 нотаток). Імпортуйте його частинами. + Цей вибір більший, ніж Encly може імпортувати за раз (64 МБ нотаток, 2 ГБ загалом або 20 000 нотаток). Імпортуйте його частинами. У цьому виборі не знайдено нотаток. Не вдалося прочитати вибрані файли. На цьому телефоні немає застосунку для вибору файлів, який може використати Encly. diff --git a/app/src/main/res/values/strings_import.xml b/app/src/main/res/values/strings_import.xml index 89d884eb..991b207b 100644 --- a/app/src/main/res/values/strings_import.xml +++ b/app/src/main/res/values/strings_import.xml @@ -61,7 +61,7 @@ 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 or 20,000 notes). Import it in smaller parts. + 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. 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